Files
vmall/apps/mall/pages/merchant/join.vue
T
james 9904696e76 feat: wave 2 migration (P3, P5, P7 openspec changes)
Implements, verifies, and archives the three remaining Wave 2 changes from
openspec/MIGRATION-PLAN.md.

- add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and
  one-time admin review, paginated own fund entries, idempotent per-shop
  weekly/monthly settlement statements with commission rate and one-time
  payout confirmation.
- add-merchant-onboarding (P5): personal/enterprise applications with one live
  application per user, guarded review with mandatory rejection reason, and
  transactional shop + owner provisioning returning one-time credentials;
  mall onboarding/status pages and an admin review console.
- add-membership-messaging (P7): platform member levels, append-only growth
  accrual on order completion with guarded one-way leveling, order/shipment/
  refund system messages with unread/read state and soft deletion, plus the
  mall header unread badge.

Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding,
membership and messaging modules, event hooks in order/fulfillment/aftersale,
and integration suites for each. Shared contract extended and all three
frontends updated; code indexes, domain docs, backend guidelines and the
migration tracker synced.

Verification: cargo test -p vmall-api green twice consecutively; mall, admin
and shop-admin builds pass; browser smoke on every new surface; openspec
validate --all --strict green (33 passed).

The three changes share the @vmall/shared contract, the mall mock adapter and
per-app locale/nav files, so they are committed together to keep every commit
buildable.
2026-09-25 15:25:29 +00:00

673 lines
23 KiB
Vue

<script setup lang="ts">
import { ApiError, t as pick } from "@vmall/shared";
import type {
Category,
LocalizedText,
MerchantApplicationInput,
MerchantContact,
MerchantEntityType,
} from "@vmall/shared";
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const session = useSessionStore();
const route = useRoute();
const STEPS = ["merchant.stepKind", "merchant.stepEntity", "merchant.stepContact"];
const DRAFT_KEY = "vmall.merchant.draft";
const EMAIL_RE = /^\S+@\S+\.\S+$/;
const ENTITY_KINDS: MerchantEntityType[] = ["personal", "enterprise"];
const step = ref(0);
const entityType = ref<MerchantEntityType>("personal");
const realName = ref("");
const companyName = ref("");
const businessLicenseNo = ref("");
const categoryIds = ref<string[]>([]);
const contactName = ref("");
const contactPhone = ref("");
const contactEmail = ref("");
const contactAddress = ref("");
const identityDocumentUrl = ref("");
const businessLicenseUrl = ref("");
const extraMaterials = ref<string[]>([""]);
const categories = ref<Category[]>([]);
const categoriesFailed = ref(false);
const loadingCategories = ref(true);
const submitting = ref(false);
const success = ref(false);
const restoredDraft = ref(false);
const conflictKey = ref("");
const errorKey = ref("");
type DraftField =
| "realName"
| "companyName"
| "businessLicenseNo"
| "categoryIds"
| "contactName"
| "contactPhone"
| "contactEmail"
| "identityDocumentUrl"
| "businessLicenseUrl"
| "extraMaterials";
/** Values are i18n keys; the template resolves them so copy stays locale-aware. */
const errors = ref<Partial<Record<DraftField, string>>>({});
const stepLabels = computed(() => STEPS.map((key) => t(key)));
const isReapply = computed(() => route.query.reapply === "1");
interface CategoryOption {
id: string;
name: LocalizedText;
depth: number;
}
/** Flattened published tree, so any nesting depth renders as indented options. */
const categoryOptions = computed<CategoryOption[]>(() => {
const byParent = new Map<string | null, Category[]>();
for (const category of categories.value) {
const list = byParent.get(category.parent_id) ?? [];
list.push(category);
byParent.set(category.parent_id, list);
}
for (const list of byParent.values()) {
list.sort((a, b) => a.position - b.position || a.slug.localeCompare(b.slug));
}
const out: CategoryOption[] = [];
const walk = (parentId: string | null, depth: number): void => {
for (const category of byParent.get(parentId) ?? []) {
out.push({ id: category.id, name: category.name, depth });
walk(category.id, depth + 1);
}
};
walk(null, 0);
return out;
});
function depthClass(depth: number): string {
if (depth <= 0) return "";
if (depth === 1) return "pl-4";
return "pl-8";
}
/** i18n key -> localized message, so VField renders nothing when a field is clean. */
function fieldError(key: DraftField): string {
const code = errors.value[key];
return code ? t(code) : "";
}
const selectedCategoryLabel = computed(() =>
categoryOptions.value
.filter((option) => categoryIds.value.includes(option.id))
.map((option) => pick(option.name, locale.value))
.join(", "),
);
function toggleCategory(id: string): void {
categoryIds.value = categoryIds.value.includes(id)
? categoryIds.value.filter((entry) => entry !== id)
: [...categoryIds.value, id];
delete errors.value.categoryIds;
}
function isHttpUrl(value: string): boolean {
const trimmed = value.trim();
return (
(trimmed.startsWith("http://") || trimmed.startsWith("https://")) &&
trimmed.length > "https://".length &&
!/\s/.test(trimmed)
);
}
function nonEmptyMaterials(): string[] {
return extraMaterials.value.map((url) => url.trim()).filter(Boolean);
}
function validateEntity(): boolean {
const next: Partial<Record<DraftField, string>> = {};
if (entityType.value === "personal") {
// Switching kind drops the other kind's errors so a hidden field cannot block.
delete errors.value.companyName;
delete errors.value.businessLicenseNo;
if (!realName.value.trim()) next.realName = "merchant.errorRequired";
} else {
delete errors.value.realName;
if (!companyName.value.trim()) next.companyName = "merchant.errorRequired";
if (!businessLicenseNo.value.trim()) next.businessLicenseNo = "merchant.errorRequired";
}
if (categoryIds.value.length === 0) next.categoryIds = "merchant.errorCategories";
errors.value = { ...errors.value, ...next };
return Object.keys(next).length === 0;
}
function validateContact(): boolean {
const next: Partial<Record<DraftField, string>> = {};
if (!contactName.value.trim()) next.contactName = "merchant.errorRequired";
if (!contactPhone.value.trim()) next.contactPhone = "merchant.errorRequired";
if (!contactEmail.value.trim()) next.contactEmail = "merchant.errorRequired";
else if (!EMAIL_RE.test(contactEmail.value.trim())) next.contactEmail = "merchant.errorEmail";
if (entityType.value === "personal") {
delete errors.value.businessLicenseUrl;
if (!identityDocumentUrl.value.trim()) {
next.identityDocumentUrl = "merchant.errorRequired";
} else if (!isHttpUrl(identityDocumentUrl.value)) {
next.identityDocumentUrl = "merchant.errorUrl";
}
} else {
delete errors.value.identityDocumentUrl;
if (!businessLicenseUrl.value.trim()) {
next.businessLicenseUrl = "merchant.errorRequired";
} else if (!isHttpUrl(businessLicenseUrl.value)) {
next.businessLicenseUrl = "merchant.errorUrl";
}
}
if (nonEmptyMaterials().some((url) => !isHttpUrl(url))) {
next.extraMaterials = "merchant.errorUrl";
}
errors.value = { ...errors.value, ...next };
return Object.keys(next).length === 0;
}
function next(): void {
if (step.value === 0) {
// The kind step has no required input; entity validation starts on step 1.
step.value = 1;
return;
}
if (!validateEntity()) return;
step.value = Math.min(2, step.value + 1);
}
function previous(): void {
errorKey.value = "";
conflictKey.value = "";
step.value = Math.max(0, step.value - 1);
}
function buildInput(): MerchantApplicationInput {
const address = contactAddress.value.trim();
const contact: MerchantContact = {
name: contactName.value.trim(),
phone: contactPhone.value.trim(),
email: contactEmail.value.trim(),
...(address ? { address } : {}),
};
const extra = nonEmptyMaterials();
if (entityType.value === "personal") {
return {
entity_type: "personal",
real_name: realName.value.trim(),
category_ids: [...categoryIds.value],
contact,
qualification: {
identity_document_url: identityDocumentUrl.value.trim(),
...(extra.length ? { extra_materials: extra } : {}),
},
};
}
return {
entity_type: "enterprise",
company_name: companyName.value.trim(),
category_ids: [...categoryIds.value],
contact,
qualification: {
business_license_url: businessLicenseUrl.value.trim(),
business_license_no: businessLicenseNo.value.trim(),
...(extra.length ? { extra_materials: extra } : {}),
},
};
}
interface MerchantDraft {
/** Restored step, so a signed-out submit returns to the completed form. */
step: number;
entityType: MerchantEntityType;
realName: string;
companyName: string;
businessLicenseNo: string;
categoryIds: string[];
contactName: string;
contactPhone: string;
contactEmail: string;
contactAddress: string;
identityDocumentUrl: string;
businessLicenseUrl: string;
extraMaterials: string[];
}
function currentDraft(): MerchantDraft {
return {
step: step.value,
entityType: entityType.value,
realName: realName.value,
companyName: companyName.value,
businessLicenseNo: businessLicenseNo.value,
categoryIds: [...categoryIds.value],
contactName: contactName.value,
contactPhone: contactPhone.value,
contactEmail: contactEmail.value,
contactAddress: contactAddress.value,
identityDocumentUrl: identityDocumentUrl.value,
businessLicenseUrl: businessLicenseUrl.value,
extraMaterials: [...extraMaterials.value],
};
}
function applyDraft(draft: MerchantDraft): void {
step.value = Math.min(2, Math.max(0, draft.step));
entityType.value = draft.entityType;
realName.value = draft.realName;
companyName.value = draft.companyName;
businessLicenseNo.value = draft.businessLicenseNo;
categoryIds.value = [...draft.categoryIds];
contactName.value = draft.contactName;
contactPhone.value = draft.contactPhone;
contactEmail.value = draft.contactEmail;
contactAddress.value = draft.contactAddress;
identityDocumentUrl.value = draft.identityDocumentUrl;
businessLicenseUrl.value = draft.businessLicenseUrl;
extraMaterials.value = draft.extraMaterials.length ? [...draft.extraMaterials] : [""];
}
function saveDraft(): void {
if (!import.meta.client) return;
try {
sessionStorage.setItem(DRAFT_KEY, JSON.stringify(currentDraft()));
} catch {
/* storage unavailable: the completed form stays in memory until sign-in */
}
}
function clearDraft(): void {
if (!import.meta.client) return;
try {
sessionStorage.removeItem(DRAFT_KEY);
} catch {
/* storage unavailable */
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function draftText(source: Record<string, unknown>, key: string): string {
const value = source[key];
return typeof value === "string" ? value : "";
}
function draftList(source: Record<string, unknown>, key: string): string[] {
const value = source[key];
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}
function draftNumber(source: Record<string, unknown>, key: string): number {
const value = source[key];
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function readDraft(): MerchantDraft | null {
if (!import.meta.client) return null;
try {
const raw = sessionStorage.getItem(DRAFT_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) return null;
const kind = parsed.entityType;
if (kind !== "personal" && kind !== "enterprise") return null;
return {
step: draftNumber(parsed, "step"),
entityType: kind,
realName: draftText(parsed, "realName"),
companyName: draftText(parsed, "companyName"),
businessLicenseNo: draftText(parsed, "businessLicenseNo"),
categoryIds: draftList(parsed, "categoryIds"),
contactName: draftText(parsed, "contactName"),
contactPhone: draftText(parsed, "contactPhone"),
contactEmail: draftText(parsed, "contactEmail"),
contactAddress: draftText(parsed, "contactAddress"),
identityDocumentUrl: draftText(parsed, "identityDocumentUrl"),
businessLicenseUrl: draftText(parsed, "businessLicenseUrl"),
extraMaterials: draftList(parsed, "extraMaterials"),
};
} catch {
return null;
}
}
function addMaterial(): void {
extraMaterials.value.push("");
}
function removeMaterial(index: number): void {
if (extraMaterials.value.length === 1) {
extraMaterials.value[0] = "";
return;
}
extraMaterials.value.splice(index, 1);
}
async function loadCategories(): Promise<void> {
loadingCategories.value = true;
categoriesFailed.value = false;
try {
categories.value = await $api.listCategories();
} catch {
categories.value = [];
categoriesFailed.value = true;
} finally {
loadingCategories.value = false;
}
}
async function submit(): Promise<void> {
errorKey.value = "";
conflictKey.value = "";
const entityOk = validateEntity();
const contactOk = validateContact();
if (!entityOk || !contactOk) {
if (!entityOk) step.value = 1;
errorKey.value = "merchant.invalid";
return;
}
if (!session.isLoggedIn) {
// Keep the completed form, sign in, then land back here to submit.
saveDraft();
await navigateTo(signInPath("/merchant/join"));
return;
}
submitting.value = true;
try {
await $api.submitMerchantApplication(buildInput());
clearDraft();
success.value = true;
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
conflictKey.value = "merchant.conflict";
} else if (error instanceof ApiError && error.status === 401) {
saveDraft();
await navigateTo(signInPath("/merchant/join"));
} else if (error instanceof ApiError && error.status === 400) {
errorKey.value = "merchant.invalid";
} else {
errorKey.value = "merchant.submitFailed";
}
} finally {
submitting.value = false;
}
}
onMounted(() => {
if (!session.token) session.hydrate();
const restored = isReapply.value ? null : readDraft();
if (isReapply.value) clearDraft();
if (restored) {
applyDraft(restored);
restoredDraft.value = true;
} else if (!contactEmail.value && session.user?.email) {
contactEmail.value = session.user.email;
}
void loadCategories();
});
</script>
<template>
<div class="max-w-mall mx-auto px-4">
<VPage :title="t('merchant.joinTitle')">
<p class="text-muted -mt-2 mb-4 max-w-[760px] text-sm">{{ t("merchant.joinSubtitle") }}</p>
<VCard v-if="success" class="max-w-[640px]">
<h2 class="text-text m-0 text-lg font-semibold">{{ t("merchant.successTitle") }}</h2>
<p class="text-muted mt-2 text-sm">{{ t("merchant.successBody") }}</p>
<NuxtLink class="text-primary hover:text-primary-hover mt-4 inline-block text-sm" to="/merchant/status">
{{ t("merchant.viewStatus") }}
</NuxtLink>
</VCard>
<VCard v-else class="max-w-[760px]">
<UiStepBar :steps="stepLabels" :active="step" />
<p v-if="restoredDraft" role="status" class="text-success mb-3 text-sm">
{{ t("merchant.restoredDraft") }}
</p>
<!-- novalidate: our localized inline validation is authoritative, so the
browser's native bubbles never pre-empt it. -->
<form novalidate @submit.prevent="submit">
<!-- Step 1: entity kind -->
<fieldset v-if="step === 0" class="m-0 border-0 p-0">
<legend class="text-text mb-2 text-base font-semibold">
{{ t("merchant.stepKind") }}
</legend>
<p class="text-muted mb-3 text-xs">{{ t("merchant.kindHint") }}</p>
<div class="grid gap-2 sm:grid-cols-2">
<label
v-for="kind in ENTITY_KINDS"
:key="kind"
class="border-border flex cursor-pointer items-center gap-3 rounded-md border p-4 text-sm"
:class="entityType === kind ? 'border-primary bg-primary-soft' : ''"
>
<input v-model="entityType" type="radio" name="entityType" :value="kind" />
<span class="text-text font-medium">{{
kind === "personal" ? t("merchant.kindPersonal") : t("merchant.kindEnterprise")
}}</span>
</label>
</div>
</fieldset>
<!-- Step 2: entity information + operating categories -->
<fieldset v-else-if="step === 1" class="m-0 border-0 p-0">
<legend class="text-text mb-3 text-base font-semibold">
{{ t("merchant.entityInfo") }}
</legend>
<template v-if="entityType === 'personal'">
<VField
:label="t('merchant.realName')"
:error="fieldError('realName')"
>
<VInput
v-model="realName"
type="text"
:placeholder="t('merchant.realNamePlaceholder')"
/>
</VField>
</template>
<template v-else>
<VField
:label="t('merchant.companyName')"
:error="fieldError('companyName')"
>
<VInput
v-model="companyName"
type="text"
:placeholder="t('merchant.companyNamePlaceholder')"
/>
</VField>
<VField
:label="t('merchant.businessLicenseNo')"
:error="fieldError('businessLicenseNo')"
>
<VInput
v-model="businessLicenseNo"
type="text"
:placeholder="t('merchant.businessLicenseNoPlaceholder')"
/>
</VField>
</template>
<VField
:label="t('merchant.categories')"
:error="fieldError('categoryIds')"
>
<p class="text-muted mb-2 text-xs">{{ t("merchant.categoriesHint") }}</p>
<div
v-if="loadingCategories"
class="border-border text-muted rounded-md border p-3 text-sm"
>
{{ t("common.loading") }}
</div>
<p v-else-if="categoriesFailed" role="alert" class="text-danger m-0 text-sm">
{{ t("merchant.categoriesLoadFailed") }}
</p>
<div
v-else
class="border-border max-h-64 overflow-y-auto rounded-md border p-3"
role="group"
:aria-label="t('merchant.categories')"
>
<label
v-for="option in categoryOptions"
:key="option.id"
class="flex cursor-pointer items-center gap-2 py-1 text-sm"
:class="depthClass(option.depth)"
>
<input
type="checkbox"
:value="option.id"
:checked="categoryIds.includes(option.id)"
@change="toggleCategory(option.id)"
/>
<span class="text-text">{{ pick(option.name, locale) }}</span>
</label>
</div>
</VField>
</fieldset>
<!-- Step 3: contact details + qualification URLs -->
<fieldset v-else class="m-0 border-0 p-0">
<legend class="text-text mb-3 text-base font-semibold">
{{ t("merchant.contactInfo") }}
</legend>
<VField
:label="t('merchant.contactName')"
:error="fieldError('contactName')"
>
<VInput
v-model="contactName"
type="text"
:placeholder="t('merchant.contactNamePlaceholder')"
/>
</VField>
<VField
:label="t('merchant.contactPhone')"
:error="fieldError('contactPhone')"
>
<VInput
v-model="contactPhone"
type="text"
:placeholder="t('merchant.contactPhonePlaceholder')"
/>
</VField>
<VField
:label="t('merchant.contactEmail')"
:error="fieldError('contactEmail')"
>
<VInput
v-model.trim="contactEmail"
type="email"
:placeholder="t('merchant.contactEmailPlaceholder')"
/>
</VField>
<VField :label="t('merchant.contactAddress')">
<VInput
v-model="contactAddress"
type="text"
:placeholder="t('merchant.contactAddressPlaceholder')"
/>
</VField>
<h3 class="text-text mt-5 mb-1 text-base font-semibold">
{{ t("merchant.qualification") }}
</h3>
<p class="text-muted mb-3 text-xs">{{ t("merchant.qualificationHint") }}</p>
<VField
v-if="entityType === 'personal'"
:label="t('merchant.identityDocumentUrl')"
:error="fieldError('identityDocumentUrl')"
>
<VInput
v-model="identityDocumentUrl"
type="url"
:placeholder="t('merchant.identityDocumentUrlPlaceholder')"
/>
</VField>
<template v-else>
<VField
:label="t('merchant.businessLicenseUrl')"
:error="fieldError('businessLicenseUrl')"
>
<VInput
v-model="businessLicenseUrl"
type="url"
:placeholder="t('merchant.businessLicenseUrlPlaceholder')"
/>
</VField>
</template>
<VField
:label="t('merchant.extraMaterials')"
:error="fieldError('extraMaterials')"
>
<div class="grid gap-2">
<div v-for="(url, index) in extraMaterials" :key="index" class="flex gap-2">
<VInput v-model="extraMaterials[index]" :placeholder="t('merchant.extraMaterialPlaceholder')" />
<VBtn type="button" @click="removeMaterial(index)">
{{ t("merchant.extraMaterialRemove") }}
</VBtn>
</div>
<VBtn class="w-fit" type="button" @click="addMaterial">
{{ t("merchant.extraMaterialAdd") }}
</VBtn>
</div>
</VField>
<VPanel class="mt-4" :title="t('merchant.stepEntity')">
<dl class="text-muted m-0 grid gap-1 text-sm">
<div class="flex gap-2">
<dt>{{ t("merchant.entityKind") }}:</dt>
<dd class="text-text m-0">
{{
entityType === "personal"
? t("merchant.kindPersonal")
: t("merchant.kindEnterprise")
}}
</dd>
</div>
<div v-if="selectedCategoryLabel" class="flex gap-2">
<dt>{{ t("merchant.categories") }}:</dt>
<dd class="text-text m-0">{{ selectedCategoryLabel }}</dd>
</div>
</dl>
</VPanel>
</fieldset>
<p v-if="conflictKey" role="alert" class="text-danger mt-4 mb-0 text-sm">
{{ t(conflictKey) }}
</p>
<p v-else-if="errorKey" role="alert" class="text-danger mt-4 mb-0 text-sm">
{{ t(errorKey) }}
</p>
<div class="mt-4 flex flex-wrap items-center gap-2">
<VBtn v-if="step > 0" type="button" :disabled="submitting" @click="previous">
{{ t("merchant.previous") }}
</VBtn>
<VBtn v-if="step < 2" variant="primary" type="button" @click="next">
{{ t("merchant.next") }}
</VBtn>
<VBtn v-else variant="primary" type="submit" :disabled="submitting">
{{ submitting ? t("merchant.submitting") : t("merchant.submit") }}
</VBtn>
</div>
<p v-if="!session.isLoggedIn" class="text-muted mt-3 mb-0 text-xs">
{{ t("merchant.signInToSubmit") }}
</p>
</form>
</VCard>
</VPage>
</div>
</template>