feat(webDev): add inquiry modal for pricing plans

This commit introduces a 'Contact Sales' modal on the web development page, allowing users to inquire about specific service plans.

- The pricing plan buttons now trigger this modal, pre-filled with the selected plan's details.
- Users can add custom remarks to their inquiry.
- On submission, a pre-formatted message is generated and opened in WhatsApp using a new `useWhatsAppMsgSender` composable.
- Adds `NUXT_PUBLIC_WHATSAPP_NUMBER` to the runtime configuration.
- Refactors content validation by introducing Zod schemas for `PricingPlan` and `Button` props to improve type safety.
- Adds new i18n keys for the modal interface and message templates.
This commit is contained in:
xiaomai
2025-11-07 11:04:14 +08:00
parent 40b3ee147f
commit ccfd268682
14 changed files with 393 additions and 40 deletions

View File

@@ -0,0 +1,206 @@
<template>
<UModal
v-model:open="open"
:title="
$t('webDev.know_more_title', {
plan: pkg?.planTitle || '—',
})
"
:description="
$t('webDev.know_more_description', {
plan: pkg?.planTitle || '—',
})
"
aria-label="Contact Sales Modal"
>
<template #body>
<div v-if="!pkg" class="py-6">
<!-- 占位 / loading -->
<div class="text-center text-sm text-muted">
{{ $t("webDev.loading_plan", "Loading plan...") }}
</div>
</div>
<UForm
v-else
ref="form"
:schema="schema"
:state="state"
class="space-y-4"
@submit="onSubmit"
>
<p class="text-lg font-medium">
{{
$t("webDev.contact_intro", {
service: pkg.serviceTitle,
plan: pkg.planTitle,
})
}}
</p>
<p class="text-lg font-medium">
{{ $t("webDev.which_provides", "Which provides:") }}
</p>
<ul class="space-y-1">
<li v-for="(feature, idx) in pkg.features" :key="idx">
<UIcon
size="16"
name="mdi:check-circle-outline"
class="text-primary-500 mr-2"
/>{{ feature }}
</li>
</ul>
<p class="text-lg font-medium">
{{
$t("webDev.extra_remarks_title", "Besides that, I'd like to add:")
}}
</p>
<UFormField name="remarks">
<UTextarea
v-model="state.remarks"
:placeholder="
$t(
'webDev.remarks_placeholder',
'Enter your remarks or requirements, one per line'
)
"
class="w-full"
:rows="4"
/>
</UFormField>
</UForm>
</template>
<template #footer>
<div class="w-full flex gap-4">
<UButton
:label="$t('common.button.cancel')"
variant="subtle"
color="neutral"
class="flex-1"
@click="closeModal"
:disabled="isSubmitting"
/>
<UButton
:label="
isSubmitting
? $t('common.button.saving', 'Saving...')
: $t('common.button.submit')
"
variant="solid"
color="success"
class="flex-1"
@click="handleSubmit"
:loading="isSubmitting"
/>
</div>
</template>
</UModal>
</template>
<script lang="ts" setup>
import * as z from "zod";
import type { FormSubmitEvent } from "@nuxt/ui";
/* ----- types ----- */
type ContactSalesModalProps = {
serviceTitle: string;
planTitle: string;
startingPrice?: string;
features: string[];
};
/* ----- reactive state ----- */
const pkg = ref<ContactSalesModalProps | null>(null);
const open = ref<boolean>(false);
const isSubmitting = ref(false);
/* form ref保持和你原来用法一致 */
const form = useTemplateRef("form");
/* ----- zod schema ----- */
/* 注意zod 的提示文本这里使用英文/固定字符串,若要用 i18n 需要在 validate 时把错误替换为 t(...) */
const schema = z.object({
remarks: z.string().optional(),
});
type Schema = z.infer<typeof schema>;
/* ----- 表单状态初始化为空openModal 时会填充) ----- */
const state = reactive<Partial<Schema>>({
remarks: "",
});
/* ----- 外部调用:打开 modal 并填充数据 ----- */
const openModal = (pricingPlan: ContactSalesModalProps) => {
pkg.value = { ...pricingPlan };
state.remarks = "";
open.value = true;
};
/* ----- 关闭并重置 ----- */
const closeModal = () => {
open.value = false;
// 延迟清理以防动画或确认逻辑中读取到空
setTimeout(() => {
pkg.value = null;
state.remarks = "";
// 如果需要也可以重置表单验证状态: form.value?.reset()
}, 200);
};
/* ----- 表单提交处理 ----- */
async function onSubmit(event: FormSubmitEvent<Schema>) {
// event.data 已通过 schema 验证
try {
isSubmitting.value = true;
const payload = {
planTitle: pkg.value?.planTitle,
features: pkg.value?.features,
remarks: (event.data.remarks || "")
.split("\n")
.map((s) => s.trim())
.filter(Boolean),
// 可以加上更多 metadata比如 timestamp / user id / source page
};
// TODO: 在此发送到后端 API例如
// await $fetch('/api/contact-sales', { method: 'POST', body: payload });
// 临时方案,发送到 WhatsApp 去
const wa_msg = $t("webDev.whatsapp_message", {
service: pkg.value?.serviceTitle,
plan: pkg.value?.planTitle,
price: pkg.value?.startingPrice,
featureList: pkg.value?.features.map((f) => `${f}`).join("\n"),
remarkList: (event.data.remarks || "")
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
.map((r) => `- ${r}`)
.join("\n"),
});
useWhatsAppMsgSender().sendMessage(wa_msg);
// 成功提示(如果你有全局 toast/notification e.g. useToast().success(...)
// 关闭并清理
closeModal();
} catch (err) {
// 处理错误(显示错误 toast / 控制台)
console.error("submit failed", err);
// 这里可以显示友好的错误信息,例如: useToast().error(t('webDev.submit_failed'))
} finally {
isSubmitting.value = false;
}
}
/* footer 按钮触发的提交(触发表单验证) */
async function handleSubmit() {
await form.value?.submit();
}
/* 暴露给父组件的 API */
defineExpose({ openModal, closeModal });
</script>

View File

@@ -0,0 +1,21 @@
import { createSharedComposable } from "@vueuse/core";
const _useWhatsAppMsgSender = () => {
const config = useRuntimeConfig();
const phone = config.public.whatsappNumber;
// --- WhatsApp 自动消息逻辑 ---
const sendMessage = (message: string) => {
const text = encodeURIComponent(message);
const url = `https://api.whatsapp.com/send?phone=${phone}&text=${text}`;
window.open(url, "_blank");
};
return {
sendMessage,
};
};
export const useWhatsAppMsgSender = createSharedComposable(
_useWhatsAppMsgSender
);

View File

@@ -3,7 +3,7 @@
<UHeader
:ui="{
left: 'flex items-center gap-1.5',
center: 'hidden lg:flex lg:flex-16',
center: 'hidden lg:flex lg:flex-4',
right: 'flex items-center justify-end gap-1.5',
}"
>

View File

@@ -4,20 +4,44 @@
<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]
>
<UTabs :items="serviceTabs">
<template v-for="service in serviceTabs" :key="service.id" #[service.id]>
<UPricingPlans :plans="service.plans" />
</template>
</UTabs>
<WebDevContactSalesModal ref="webDevContactSalesModal" />
</UContainer>
</template>
<script lang="ts" setup>
import WebDevContactSalesModal from "~/components/webDev/ContactSalesModal.vue";
const { data: page } = await useLocalizedCollection("webDev");
const webDevContactSalesModal = ref<InstanceType<
typeof WebDevContactSalesModal
> | null>(null);
const serviceTabs = computed(() =>
page.value?.services.map((srv) => ({
...srv,
plans: srv.plans.map((pln) => ({
...pln,
button: {
label: "立刻咨询", // TODO: i18n 适配
onClick: () => {
webDevContactSalesModal.value?.openModal({
serviceTitle: srv.label,
planTitle: pln.title,
startingPrice: pln.price,
features: pln.features ?? [],
});
},
},
})),
slot: srv.id,
}))
);
</script>
<style></style>

View File

@@ -0,0 +1,37 @@
// buttonSchema.ts
import { z } from "zod";
const ButtonSize = z.enum(["2xs", "xs", "sm", "md", "lg", "xl"]);
const ButtonColor = z.enum([
"error",
"primary",
"secondary",
"success",
"info",
"warning",
"neutral",
]);
const ButtonVariant = z.enum(["solid", "outline", "soft", "ghost", "link"]);
export const ButtonPropsSchema = z.object({
size: ButtonSize.optional().default("sm"),
type: z.enum(["button", "submit", "reset"]).optional().default("button"),
label: z.string().optional().default(""),
color: ButtonColor.optional().default("primary"),
variant: ButtonVariant.optional().default("solid"),
icon: z.string().optional().default(""),
leading: z.boolean().optional().default(false),
trailing: z.boolean().optional().default(false),
disabled: z.boolean().optional().default(false),
loading: z.boolean().optional().default(false),
loadingIcon: z.string().optional().default("i-heroicons-arrow-path-20-solid"),
block: z.boolean().optional().default(false),
to: z.string().optional().default(""),
target: z.string().optional().default(""),
padded: z.boolean().optional().default(true),
square: z.boolean().optional().default(false),
truncate: z.boolean().optional().default(false),
attrs: z.record(z.any()).optional().default({}),
});
export type ButtonProps = z.infer<typeof ButtonPropsSchema>;

View File

@@ -0,0 +1,57 @@
import { z } from "zod";
import { ButtonPropsSchema } from "./buttonSchema";
/**
* Zod schema for UPricingPlan component (basic props only)
* Reference: https://ui.nuxt.com/docs/components/pricing-plan#props
*/
export const PricingPlanPropsSchema = z.object({
/** The title of the pricing plan. */
title: z.string(),
/** The description text shown under the title. */
description: z.string().optional(),
/** The current price of the plan. */
price: z.string().optional(),
/**
* Discounted price.
* When set, the main price will appear with a strikethrough.
*/
discount: z.string().optional(),
/** The unit period (e.g. /month) displayed next to price. */
billingCycle: z.string().optional(),
/** Additional billing text above the billing cycle. */
billingPeriod: z.string().optional(),
/**
* List of plan features.
* Can be an array of strings or array of objects (feature items).
*/
features: z.array(z.string()).optional().default([]),
/** The button displayed at the bottom (ButtonProps). */
button: ButtonPropsSchema.optional(),
/**
* Visual variant of the pricing plan.
* @default "outline"
*/
variant: z.enum(["soft", "solid", "outline", "subtle"]).optional().default("outline"),
/**
* Layout orientation of the component.
* @default "vertical"
*/
orientation: z.enum(["vertical", "horizontal"]).optional().default("vertical"),
/** Optional tagline text displayed above price. */
tagline: z.string().optional(),
/** Terms or disclaimer text displayed below features. */
terms: z.string().optional(),
/**
* Highlights the pricing plan visually (adds a ring around it).
* @default false
*/
highlight: z.boolean().optional().default(false),
/**
* Enlarges the plan card slightly for emphasis.
* @default false
*/
scale: z.boolean().optional().default(false),
});
export type PricingPlanProps = z.infer<typeof PricingPlanPropsSchema>;