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.
673 lines
23 KiB
Vue
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>
|