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.
This commit is contained in:
@@ -0,0 +1,672 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user