feat(pages): add web development services page
This commit introduces a new page at `/webDev` to display web development services and pricing plans. To support this, a new reusable composable `useLocalizedCollection` has been created to simplify fetching localized content from Nuxt Content. The index page has been refactored to use this new composable. - Adds `webDev.vue` page and corresponding `webDev.yml` content files for EN and ZH. - Defines a Zod schema in `content.config.ts` for the new content type. - Updates the navigation link to point to the new page.
This commit is contained in:
66
app/composables/LocalizedCollection.ts
Normal file
66
app/composables/LocalizedCollection.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// /composables/useLocalizedCollection.ts
|
||||||
|
import type { Collections } from "@nuxt/content";
|
||||||
|
|
||||||
|
export type UseLocalizedOptions = {
|
||||||
|
/** 默认 locale -> suffix 映射 */
|
||||||
|
localeMap?: Record<string, string>;
|
||||||
|
/** 回退 locale 的 suffix(例如 'en') */
|
||||||
|
fallbackSuffix?: string;
|
||||||
|
/** 当找不到内容时是否抛错,默认 true */
|
||||||
|
throwOnMissing?: boolean;
|
||||||
|
/** useAsyncData 的 key 前缀(默认等于 baseName) */
|
||||||
|
keyPrefix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asCollectionKey(key: string) {
|
||||||
|
return key as keyof Collections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带类型安全的多语言内容加载器
|
||||||
|
* @example
|
||||||
|
* const { data: page } = await useLocalizedCollection('index')
|
||||||
|
*/
|
||||||
|
export function useLocalizedCollection<
|
||||||
|
B extends string, // 基础名称
|
||||||
|
>(baseName: B, opts: UseLocalizedOptions = {}) {
|
||||||
|
const { locale } = useI18n();
|
||||||
|
|
||||||
|
const localeMap = opts.localeMap ?? { en: "en", "zh-CN": "zh" };
|
||||||
|
const fallbackSuffix = opts.fallbackSuffix ?? "en";
|
||||||
|
const keyPrefix = opts.keyPrefix ?? baseName;
|
||||||
|
const throwOnMissing = opts.throwOnMissing ?? true;
|
||||||
|
|
||||||
|
// 🔥 自动推断对应集合类型
|
||||||
|
type LocalizedKey = keyof {
|
||||||
|
[K in keyof Collections as K extends `${B}_${string}` ? K : never]: any;
|
||||||
|
};
|
||||||
|
type Schema = Collections[LocalizedKey];
|
||||||
|
|
||||||
|
return useAsyncData<Schema | null>(
|
||||||
|
`${keyPrefix}-${locale.value}`,
|
||||||
|
async () => {
|
||||||
|
const suffix =
|
||||||
|
localeMap[locale.value] ?? locale.value.split("-")[0] ?? "en";
|
||||||
|
const key = asCollectionKey(`${baseName}_${suffix}`);
|
||||||
|
let content = (await queryCollection(key).first()) as Schema | null;
|
||||||
|
|
||||||
|
if (!content && suffix !== fallbackSuffix) {
|
||||||
|
const fallbackKey = asCollectionKey(`${baseName}_${fallbackSuffix}`);
|
||||||
|
content = (await queryCollection(fallbackKey).first()) as Schema | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
},
|
||||||
|
{ watch: [locale] }
|
||||||
|
).then((res) => {
|
||||||
|
if (throwOnMissing && res && !res.data?.value) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: `Page not found: ${baseName} for locale ${locale.value}`,
|
||||||
|
fatal: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export const useNavLinks = () => {
|
|||||||
label: t("common.header.services.children.webDev.label"),
|
label: t("common.header.services.children.webDev.label"),
|
||||||
description: t("common.header.services.children.webDev.description"),
|
description: t("common.header.services.children.webDev.description"),
|
||||||
icon: "mdi:web",
|
icon: "mdi:web",
|
||||||
|
to: "/webDev"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("common.header.services.children.softwareDev.label"),
|
label: t("common.header.services.children.softwareDev.label"),
|
||||||
|
|||||||
@@ -71,47 +71,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Collections } from "@nuxt/content";
|
const { data: page } = await useLocalizedCollection("index");
|
||||||
|
|
||||||
const { locale } = useI18n();
|
|
||||||
|
|
||||||
const { data: page } = await useAsyncData(
|
|
||||||
"index-" + locale.value,
|
|
||||||
async () => {
|
|
||||||
// Build collection name based on current locale
|
|
||||||
let localeSuffix = "";
|
|
||||||
switch (locale.value) {
|
|
||||||
case "en":
|
|
||||||
localeSuffix = "en";
|
|
||||||
break;
|
|
||||||
case "zh-CN":
|
|
||||||
localeSuffix = "zh";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
localeSuffix = "en";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const collection = ("index_" + localeSuffix) as keyof Collections;
|
|
||||||
const content = await queryCollection(collection).first();
|
|
||||||
|
|
||||||
// Optional: fallback to default locale if content is missing
|
|
||||||
if (!content && locale.value !== "en") {
|
|
||||||
return await queryCollection("index_en").first();
|
|
||||||
}
|
|
||||||
|
|
||||||
return content;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
watch: [locale], // Refetch when locale changes
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!page.value) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 404,
|
|
||||||
statusMessage: `Page not found, the index_${locale.value} couldn't be found.`,
|
|
||||||
fatal: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: page.value?.seo.title,
|
title: page.value?.seo.title,
|
||||||
|
|||||||
23
app/pages/webDev.vue
Normal file
23
app/pages/webDev.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<UContainer>
|
||||||
|
<UPageHero :title="page?.title" :description="page?.description" />
|
||||||
|
|
||||||
|
<p class="text-muted py-4 text-center">{{ page?.remarks }}</p>
|
||||||
|
|
||||||
|
<UTabs :items="page?.services.map((s) => ({ ...s, slot: s.id }))">
|
||||||
|
<template
|
||||||
|
v-for="service in page?.services"
|
||||||
|
:key="service.id"
|
||||||
|
#[service.id]
|
||||||
|
>
|
||||||
|
<UPricingPlans :plans="service.plans" />
|
||||||
|
</template>
|
||||||
|
</UTabs>
|
||||||
|
</UContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const { data: page } = await useLocalizedCollection("webDev");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style></style>
|
||||||
@@ -40,6 +40,54 @@ const defineIndexSchema = () =>
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const defineWebDevSchema = () =>
|
||||||
|
z.object({
|
||||||
|
remarks: z.string(),
|
||||||
|
services: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
icon: z.string().optional(), // 比如 "lucide:mouse-pointer-click"
|
||||||
|
// 你原结构里通过 createService 包装,但最终是一个对象
|
||||||
|
plans: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
price: z.string().optional(), // 保持为 string(例如 "RM499 起"),如需更严可用 regex
|
||||||
|
discount: z.string().optional(),
|
||||||
|
tagline: z.string().optional(),
|
||||||
|
highlight: z.boolean().optional().default(false),
|
||||||
|
features: z.array(z.string()).optional().default([]),
|
||||||
|
button: z
|
||||||
|
.object({
|
||||||
|
label: z.string().min(1),
|
||||||
|
href: z.string().url().optional(), // 如果你可能会传链接
|
||||||
|
// 预留扩展字段(例如:variant、target 等)
|
||||||
|
variant: z
|
||||||
|
.enum([
|
||||||
|
"link",
|
||||||
|
"solid",
|
||||||
|
"outline",
|
||||||
|
"soft",
|
||||||
|
"subtle",
|
||||||
|
"ghost",
|
||||||
|
])
|
||||||
|
.default("subtle"),
|
||||||
|
target: z.enum(["_self", "_blank"]).optional(),
|
||||||
|
})
|
||||||
|
.partial({ href: true, variant: true, target: true }) // 全部可选除 label(如果你希望 label 也可选,可把 min(1) 去掉或改 optional)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
// 预留扩展字段(例如:category、tags、hidden 等)
|
||||||
|
category: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
export default defineContentConfig({
|
export default defineContentConfig({
|
||||||
collections: {
|
collections: {
|
||||||
index_en: defineCollection({
|
index_en: defineCollection({
|
||||||
@@ -52,5 +100,15 @@ export default defineContentConfig({
|
|||||||
source: "zh-CN/index.yml",
|
source: "zh-CN/index.yml",
|
||||||
schema: defineIndexSchema(),
|
schema: defineIndexSchema(),
|
||||||
}),
|
}),
|
||||||
|
webDev_en: defineCollection({
|
||||||
|
type: "page",
|
||||||
|
source: "en-US/webDev.yml",
|
||||||
|
schema: defineWebDevSchema(),
|
||||||
|
}),
|
||||||
|
webDev_zh: defineCollection({
|
||||||
|
type: "page",
|
||||||
|
source: "zh-CN/webDev.yml",
|
||||||
|
schema: defineWebDevSchema(),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
95
content/en-US/webDev.yml
Normal file
95
content/en-US/webDev.yml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
seo:
|
||||||
|
title: "Web / Website Custom Development"
|
||||||
|
description: "Create a tailor-made online portal for your brand to boost visibility and conversion rates."
|
||||||
|
title: "Web / Website Custom Development"
|
||||||
|
description: "Create a tailor-made online portal for your brand to boost visibility and conversion rates."
|
||||||
|
|
||||||
|
remarks: "All plans include basic domain and server deployment for the first year. If server load becomes too high, we will assist with migration to a more stable third-party environment."
|
||||||
|
|
||||||
|
services:
|
||||||
|
- id: landing-page
|
||||||
|
label: "Landing Page"
|
||||||
|
icon: "mdi:cursor-default-click-outline"
|
||||||
|
plans:
|
||||||
|
- title: "Basic"
|
||||||
|
description: "Ideal for individuals or small businesses to launch a one-page showcase website quickly."
|
||||||
|
price: "From RM899"
|
||||||
|
tagline: "Includes domain & hosting"
|
||||||
|
features:
|
||||||
|
- "Single-page structure (1–3 sections)"
|
||||||
|
- "Responsive design (mobile / tablet / desktop)"
|
||||||
|
- "Basic text and image layout"
|
||||||
|
- "Contact form or WhatsApp button"
|
||||||
|
- "Google Analytics integration"
|
||||||
|
- "1 free revision"
|
||||||
|
- "Delivery within 7 days"
|
||||||
|
button:
|
||||||
|
label: "Contact Now"
|
||||||
|
|
||||||
|
- title: "Standard"
|
||||||
|
description: "Designed for brands and startups to create high-conversion pages."
|
||||||
|
price: "From RM1,599"
|
||||||
|
tagline: "Includes domain & hosting"
|
||||||
|
highlight: true
|
||||||
|
features:
|
||||||
|
- "Multi-section layout (4–6 sections)"
|
||||||
|
- "Custom brand styling and color scheme"
|
||||||
|
- "Lightweight animations and motion effects"
|
||||||
|
- "SEO optimization + performance tuning"
|
||||||
|
- "Tracking integration (GA / Pixel)"
|
||||||
|
- "2 free revisions"
|
||||||
|
- "Delivery within 14 days"
|
||||||
|
button:
|
||||||
|
label: "Request Quote"
|
||||||
|
|
||||||
|
- title: "Premium Custom"
|
||||||
|
description: "Comprehensive visual and marketing upgrade for established brands."
|
||||||
|
price: "From RM2,999"
|
||||||
|
features:
|
||||||
|
- "Exclusive visual design and interaction experience"
|
||||||
|
- "Complete brand style system"
|
||||||
|
- "A/B testing and conversion optimization"
|
||||||
|
- "Marketing tool integration (email, analytics, CRM)"
|
||||||
|
- "Multi-language / dynamic content support"
|
||||||
|
button:
|
||||||
|
label: "Book a Custom Plan"
|
||||||
|
- id: official-web
|
||||||
|
label: "Official Website"
|
||||||
|
icon: "lucide:globe"
|
||||||
|
plans:
|
||||||
|
- title: "Basic Website"
|
||||||
|
description: "Build a professional online presence for small and medium-sized businesses."
|
||||||
|
price: "From RM3,999"
|
||||||
|
tagline: "Includes domain & hosting"
|
||||||
|
features:
|
||||||
|
- "Up to 5 pages (Home, About, Services, Contact, etc.)"
|
||||||
|
- "Responsive design (desktop / tablet / mobile)"
|
||||||
|
- "Basic SEO setup"
|
||||||
|
- "Contact form + map + social media links"
|
||||||
|
button:
|
||||||
|
label: "Contact Now"
|
||||||
|
|
||||||
|
- title: "Standard Website"
|
||||||
|
description: "Ideal for brand upgrades and content-rich businesses."
|
||||||
|
price: "From RM6,999"
|
||||||
|
# discount: "From RM4,999"
|
||||||
|
tagline: "Includes domain & hosting"
|
||||||
|
highlight: true
|
||||||
|
features:
|
||||||
|
- "Around 8–12 pages (Case Studies, Blog, Team, etc.)"
|
||||||
|
- "Custom brand styling + UI/UX optimization"
|
||||||
|
- "Lightweight CMS for content management"
|
||||||
|
- "Advanced SEO optimization and performance acceleration"
|
||||||
|
button:
|
||||||
|
label: "Book Standard Plan"
|
||||||
|
|
||||||
|
- title: "Enterprise Custom"
|
||||||
|
description: "Fully tailored visual, functional, and interactive experience."
|
||||||
|
price: "From RM15,000"
|
||||||
|
features:
|
||||||
|
- "Fully custom UI / animation design"
|
||||||
|
- "Multi-language support / client login module"
|
||||||
|
- "API / third-party system integrations"
|
||||||
|
- "Enhanced security and automated backup mechanism"
|
||||||
|
button:
|
||||||
|
label: "Book Enterprise Plan"
|
||||||
95
content/zh-CN/webDev.yml
Normal file
95
content/zh-CN/webDev.yml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
seo:
|
||||||
|
title: "网页 / 网站定制开发"
|
||||||
|
description: "为您的品牌量身定做一套线上门户,增加产品曝光率与转化率。"
|
||||||
|
title: "网页 / 网站定制开发"
|
||||||
|
description: "为您的品牌量身定做一套线上门户,增加产品曝光率与转化率。"
|
||||||
|
|
||||||
|
remarks: "所有方案均含基础域名与服务器部署(首年)。若服务器负载过高,将协助迁移至更稳定的第三方环境。"
|
||||||
|
|
||||||
|
services:
|
||||||
|
- id: landing-page
|
||||||
|
label: "Landing Page"
|
||||||
|
icon: "mdi:cursor-default-click-outline"
|
||||||
|
plans:
|
||||||
|
- title: "基础版"
|
||||||
|
description: "适合个人、小型商家,快速上线单页展示网站。"
|
||||||
|
price: "RM899 起"
|
||||||
|
tagline: "含域名与服务器"
|
||||||
|
features:
|
||||||
|
- "单页面结构(1-3 屏)"
|
||||||
|
- "响应式设计(手机 / 平板 / 桌面)"
|
||||||
|
- "基本图文排版"
|
||||||
|
- "联系表单或 WhatsApp 按钮"
|
||||||
|
- "Google Analytics 整合"
|
||||||
|
- "1 次免费修改"
|
||||||
|
- "7 日内交付"
|
||||||
|
button:
|
||||||
|
label: "立即咨询"
|
||||||
|
|
||||||
|
- title: "标准版"
|
||||||
|
description: "为品牌与创业项目打造高转化页面。"
|
||||||
|
price: "RM1,599 起"
|
||||||
|
tagline: "含域名与服务器"
|
||||||
|
highlight: true
|
||||||
|
features:
|
||||||
|
- "多区块结构(4-6 屏)"
|
||||||
|
- "品牌定制风格与配色"
|
||||||
|
- "轻量动画与动效展示"
|
||||||
|
- "SEO 优化 + 加载性能优化"
|
||||||
|
- "整合追踪代码(GA / Pixel)"
|
||||||
|
- "2 次免费修改"
|
||||||
|
- "14 日内交付"
|
||||||
|
button:
|
||||||
|
label: "预约报价"
|
||||||
|
|
||||||
|
- title: "高级定制"
|
||||||
|
description: "为成熟品牌提供全面视觉与营销升级方案。"
|
||||||
|
price: "RM2,999 起"
|
||||||
|
features:
|
||||||
|
- "专属视觉设计与交互体验"
|
||||||
|
- "完整品牌风格系统"
|
||||||
|
- "A/B 测试与转化优化"
|
||||||
|
- "营销工具整合(邮件、统计、CRM)"
|
||||||
|
- "多语言 / 动态内容支持"
|
||||||
|
button:
|
||||||
|
label: "预约定制方案"
|
||||||
|
- id: official-web
|
||||||
|
label: "Official Website"
|
||||||
|
icon: "lucide:globe"
|
||||||
|
plans:
|
||||||
|
- title: "基础官网"
|
||||||
|
description: "为中小型企业建立专业在线形象。"
|
||||||
|
price: "RM3,999 起"
|
||||||
|
tagline: "含域名与服务器"
|
||||||
|
features:
|
||||||
|
- "最多 5 个页面(首页、关于、服务、联系等)"
|
||||||
|
- "响应式设计(桌面 / 平板 / 手机)"
|
||||||
|
- "基础 SEO 设置"
|
||||||
|
- "联系表单 + 地图 + 社交媒体链接"
|
||||||
|
button:
|
||||||
|
label: "立即咨询"
|
||||||
|
|
||||||
|
- title: "标准官网"
|
||||||
|
description: "适合品牌升级与内容扩展型企业。"
|
||||||
|
price: "RM6,999 起"
|
||||||
|
# discount: "RM4,999 起"
|
||||||
|
tagline: "含域名与服务器"
|
||||||
|
highlight: true
|
||||||
|
features:
|
||||||
|
- "约 8-12 个页面(案例、博客、团队等)"
|
||||||
|
- "品牌定制风格 + UI/UX 优化"
|
||||||
|
- "轻量 CMS 后台管理系统"
|
||||||
|
- "进阶 SEO 优化与性能加速"
|
||||||
|
button:
|
||||||
|
label: "预约标准方案"
|
||||||
|
|
||||||
|
- title: "企业定制"
|
||||||
|
description: "专属视觉、功能与交互体验整合。"
|
||||||
|
price: "RM15,000 起"
|
||||||
|
features:
|
||||||
|
- "完全定制 UI / 动效设计"
|
||||||
|
- "多语言支持 / 客户登录模块"
|
||||||
|
- "API / 第三方系统整合"
|
||||||
|
- "增强安全与自动备份机制"
|
||||||
|
button:
|
||||||
|
label: "预约企业方案"
|
||||||
Reference in New Issue
Block a user