wip(freight): migration 0017 + model/column-list groundwork for add-freight-templates

This commit is contained in:
Chengdong Zhang
2026-09-23 17:39:45 +08:00
parent a83e857bdf
commit 5b426486ac
105 changed files with 6193 additions and 1566 deletions
+33 -11
View File
@@ -102,7 +102,15 @@ async function remove(row: AddressBookEntry): Promise<void> {
}
async function save(): Promise<void> {
if (!form.recipient || !form.phone || !form.country || !form.region || !form.city || !form.line1 || !form.postal_code) {
if (
!form.recipient ||
!form.phone ||
!form.country ||
!form.region ||
!form.city ||
!form.line1 ||
!form.postal_code
) {
errorKey.value = "user.validationRequired";
return;
}
@@ -137,8 +145,10 @@ async function save(): Promise<void> {
<template>
<VCard class="min-h-[560px]">
<div class="mb-4 flex items-center justify-between gap-3">
<h1 class="m-0 text-xl font-bold text-text">{{ t("user.addresses") }}</h1>
<VBtn variant="primary" size="sm" type="button" @click="openAdd">{{ t("user.addAddress") }}</VBtn>
<h1 class="text-text m-0 text-xl font-bold">{{ t("user.addresses") }}</h1>
<VBtn variant="primary" size="sm" type="button" @click="openAdd">{{
t("user.addAddress")
}}</VBtn>
</div>
<UiEmptyState v-if="!loading && rows.length === 0" :text="t('user.noAddresses')" />
<VTable v-else>
@@ -165,12 +175,18 @@ async function save(): Promise<void> {
<td>{{ row.line1 }}</td>
<td>{{ row.postal_code }}</td>
<td>
<span v-if="row.is_default" class="text-xs text-primary">{{ t("user.defaultAddress") }}</span>
<VBtn v-else size="sm" type="button" @click="setDefault(row.id)">{{ t("user.setDefault") }}</VBtn>
<span v-if="row.is_default" class="text-primary text-xs">{{
t("user.defaultAddress")
}}</span>
<VBtn v-else size="sm" type="button" @click="setDefault(row.id)">{{
t("user.setDefault")
}}</VBtn>
</td>
<td class="space-x-2 whitespace-nowrap">
<VBtn size="sm" type="button" @click="openEdit(row)">{{ t("common.edit") }}</VBtn>
<VBtn size="sm" variant="danger" type="button" @click="remove(row)">{{ t("common.delete") }}</VBtn>
<VBtn size="sm" variant="danger" type="button" @click="remove(row)">{{
t("common.delete")
}}</VBtn>
</td>
</tr>
</tbody>
@@ -183,16 +199,22 @@ async function save(): Promise<void> {
<VField :label="t('user.country')"><VInput v-model="form.country" /></VField>
<VField :label="t('user.region')"><VInput v-model="form.region" /></VField>
<VField :label="t('user.city')"><VInput v-model="form.city" /></VField>
<VField class="sm:col-span-2" :label="t('user.line1')"><VInput v-model="form.line1" /></VField>
<VField class="sm:col-span-2" :label="t('user.line1')"
><VInput v-model="form.line1"
/></VField>
<VField :label="t('user.postalCode')"><VInput v-model="form.postal_code" /></VField>
<label class="flex items-center gap-2 self-end pb-3 text-sm text-text"><input v-model="form.is_default" class="h-4 w-4 accent-primary" type="checkbox" /> <span>{{ t("user.defaultAddress") }}</span></label>
<label class="text-text flex items-center gap-2 self-end pb-3 text-sm"
><input v-model="form.is_default" class="accent-primary h-4 w-4" type="checkbox" />
<span>{{ t("user.defaultAddress") }}</span></label
>
</div>
<p v-if="errorKey" class="mt-2 text-sm text-danger">{{ t(errorKey) }}</p>
<p v-if="errorKey" class="text-danger mt-2 text-sm">{{ t(errorKey) }}</p>
<template #footer>
<VBtn type="button" @click="open = false">{{ t("common.cancel") }}</VBtn>
<VBtn variant="primary" type="button" :disabled="saving" @click="save">{{ t("user.saveAddress") }}</VBtn>
<VBtn variant="primary" type="button" :disabled="saving" @click="save">{{
t("user.saveAddress")
}}</VBtn>
</template>
</UiModal>
</VCard>
</template>
+163 -41
View File
@@ -16,7 +16,13 @@ const carrier = ref("");
const trackingNo = ref("");
const message = ref("");
const evidence = ref("");
const progress: AftersaleStatus[] = ["pending", "approved", "buyer_shipping", "merchant_confirmed", "refunded"];
const progress: AftersaleStatus[] = [
"pending",
"approved",
"buyer_shipping",
"merchant_confirmed",
"refunded",
];
const id = computed(() => String(route.params.id ?? ""));
@@ -35,7 +41,9 @@ function progressTone(status: AftersaleStatus): string {
if (!detail.value) return "border-border bg-surface text-muted";
const current = progress.indexOf(detail.value.status);
const step = progress.indexOf(status);
return step >= 0 && current >= step ? "border-primary bg-primary text-white" : "border-border bg-surface text-muted";
return step >= 0 && current >= step
? "border-primary bg-primary text-white"
: "border-border bg-surface text-muted";
}
async function load(): Promise<void> {
@@ -111,7 +119,10 @@ async function sendMessage(): Promise<void> {
try {
await $api.addAftersaleMessage(detail.value.id, {
content: { en: content, zh: content },
evidence: evidence.value.split("\n").map((url) => url.trim()).filter(Boolean),
evidence: evidence.value
.split("\n")
.map((url) => url.trim())
.filter(Boolean),
});
message.value = "";
evidence.value = "";
@@ -129,89 +140,200 @@ onMounted(() => void load());
<template>
<VCard class="min-h-[560px]">
<div class="mb-4 flex items-center justify-between gap-3">
<h1 class="m-0 text-xl font-bold text-text">{{ t("user.aftersaleDetail") }}</h1>
<NuxtLink class="text-sm text-primary no-underline" to="/user/aftersales">{{ t("user.aftersalesTitle") }}</NuxtLink>
<h1 class="text-text m-0 text-xl font-bold">{{ t("user.aftersaleDetail") }}</h1>
<NuxtLink class="text-primary text-sm no-underline" to="/user/aftersales">{{
t("user.aftersalesTitle")
}}</NuxtLink>
</div>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<p v-else-if="failed || !detail" class="py-5 text-sm text-danger">{{ t("user.aftersaleLoadFailed") }}</p>
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
<p v-else-if="failed || !detail" class="text-danger py-5 text-sm">
{{ t("user.aftersaleLoadFailed") }}
</p>
<div v-else class="space-y-4">
<header class="flex items-center justify-between gap-3 border border-border bg-bg p-4 max-sm:flex-col max-sm:items-start">
<header
class="border-border bg-bg flex items-center justify-between gap-3 border p-4 max-sm:flex-col max-sm:items-start"
>
<div>
<p class="m-0 text-sm text-muted">{{ t("user.aftersaleOrder") }} {{ detail.order_id }}</p>
<p class="mt-1 text-xs text-muted">{{ t("user.aftersaleCreatedAt") }} {{ detail.created_at.slice(0, 10) }}</p>
<p class="text-muted m-0 text-sm">{{ t("user.aftersaleOrder") }} {{ detail.order_id }}</p>
<p class="text-muted mt-1 text-xs">
{{ t("user.aftersaleCreatedAt") }} {{ detail.created_at.slice(0, 10) }}
</p>
</div>
<VBadge :tone="tone(detail.status)">{{ statusLabel(detail.status) }}</VBadge>
</header>
<VCard :padded="false" class="p-5">
<h2 class="mb-3 text-[15px] font-semibold text-text">{{ t("user.aftersaleItem") }}</h2>
<h2 class="text-text mb-3 text-[15px] font-semibold">{{ t("user.aftersaleItem") }}</h2>
<div class="flex items-center gap-3">
<img class="h-14 w-14 border border-border object-contain" :src="detail.item.image || '/mock/product-1.svg'" :alt="pick(detail.item.product_name, locale)" />
<img
class="border-border h-14 w-14 border object-contain"
:src="detail.item.image || '/mock/product-1.svg'"
:alt="pick(detail.item.product_name, locale)"
/>
<div class="min-w-0 flex-1">
<p class="m-0 truncate text-sm text-text">{{ pick(detail.item.product_name, locale) }}</p>
<p class="mt-1 text-xs text-muted">{{ detail.item.sku_code }} × {{ detail.item.qty }}</p>
<p class="text-text m-0 truncate text-sm">
{{ pick(detail.item.product_name, locale) }}
</p>
<p class="text-muted mt-1 text-xs">
{{ detail.item.sku_code }} × {{ detail.item.qty }}
</p>
</div>
<PriceText :amount-minor="detail.item.unit_price_minor * detail.item.qty" :currency="detail.currency" />
<PriceText
:amount-minor="detail.item.unit_price_minor * detail.item.qty"
:currency="detail.currency"
/>
</div>
<div class="mt-4 grid gap-2 text-sm text-muted sm:grid-cols-2">
<span>{{ t("user.aftersaleType") }}: {{ t(detail.kind === "refund_only" ? "user.refundOnly" : "user.returnRefund") }}</span>
<span>{{ t("user.aftersaleAmount") }}: <PriceText :amount-minor="detail.amount_minor" :currency="detail.currency" /></span>
<span>{{ t("user.aftersaleRemaining") }}: <PriceText :amount-minor="detail.remaining_refundable_minor" :currency="detail.currency" /></span>
<div class="text-muted mt-4 grid gap-2 text-sm sm:grid-cols-2">
<span
>{{ t("user.aftersaleType") }}:
{{ t(detail.kind === "refund_only" ? "user.refundOnly" : "user.returnRefund") }}</span
>
<span
>{{ t("user.aftersaleAmount") }}:
<PriceText :amount-minor="detail.amount_minor" :currency="detail.currency"
/></span>
<span
>{{ t("user.aftersaleRemaining") }}:
<PriceText
:amount-minor="detail.remaining_refundable_minor"
:currency="detail.currency"
/></span>
<span>{{ t("user.aftersaleReason") }}: {{ pick(detail.reason, locale) }}</span>
</div>
<div v-if="detail.evidence.length" class="mt-4 flex flex-wrap gap-2 text-sm">
<a v-for="url in detail.evidence" :key="url" class="text-primary" :href="url" target="_blank" rel="noreferrer">{{ t("user.aftersaleViewEvidence") }}</a>
<a
v-for="url in detail.evidence"
:key="url"
class="text-primary"
:href="url"
target="_blank"
rel="noreferrer"
>{{ t("user.aftersaleViewEvidence") }}</a
>
</div>
</VCard>
<VCard :padded="false" class="p-5">
<h2 class="mb-4 text-[15px] font-semibold text-text">{{ t("user.aftersaleStatus") }}</h2>
<h2 class="text-text mb-4 text-[15px] font-semibold">{{ t("user.aftersaleStatus") }}</h2>
<div class="flex flex-wrap items-center gap-2">
<template v-for="(step, index) in progress" :key="step">
<span class="rounded-full border px-3 py-1 text-xs" :class="progressTone(step)">{{ statusLabel(step) }}</span>
<span class="rounded-full border px-3 py-1 text-xs" :class="progressTone(step)">{{
statusLabel(step)
}}</span>
<span v-if="index < progress.length - 1" class="text-muted">→</span>
</template>
</div>
<p v-if="detail.status === 'rejected' || detail.status === 'cancelled'" class="mt-3 text-sm text-danger">{{ statusLabel(detail.status) }}</p>
<div v-if="detail.return_carrier || detail.return_tracking_no" class="mt-3 text-sm text-muted">
{{ t("user.returnShipping") }}: {{ detail.return_carrier }} {{ detail.return_tracking_no }}
<p
v-if="detail.status === 'rejected' || detail.status === 'cancelled'"
class="text-danger mt-3 text-sm"
>
{{ statusLabel(detail.status) }}
</p>
<div
v-if="detail.return_carrier || detail.return_tracking_no"
class="text-muted mt-3 text-sm"
>
{{ t("user.returnShipping") }}: {{ detail.return_carrier }}
{{ detail.return_tracking_no }}
</div>
<div v-if="detail.status === 'refunded'" class="mt-3 text-sm text-success">
<div v-if="detail.status === 'refunded'" class="text-success mt-3 text-sm">
{{ t("user.aftersaleRefunded") }} · {{ t("user.aftersaleRefundResult") }}:
<PriceText :amount-minor="detail.amount_minor" :currency="detail.currency" />
</div>
<div class="mt-4 flex flex-wrap gap-2">
<VBtn v-if="['pending', 'approved', 'buyer_shipping', 'merchant_confirmed'].includes(detail.status)" variant="danger" size="sm" type="button" :disabled="working" @click="cancel">{{ t("user.cancelAftersale") }}</VBtn>
<VBtn v-if="detail.status === 'rejected'" variant="primary" size="sm" type="button" :disabled="working" @click="reopen">{{ t("user.reopenAftersale") }}</VBtn>
<VBtn
v-if="
['pending', 'approved', 'buyer_shipping', 'merchant_confirmed'].includes(
detail.status,
)
"
variant="danger"
size="sm"
type="button"
:disabled="working"
@click="cancel"
>{{ t("user.cancelAftersale") }}</VBtn
>
<VBtn
v-if="detail.status === 'rejected'"
variant="primary"
size="sm"
type="button"
:disabled="working"
@click="reopen"
>{{ t("user.reopenAftersale") }}</VBtn
>
</div>
</VCard>
<VCard v-if="detail.status === 'approved' && detail.kind === 'return_refund'" :padded="false" class="p-5">
<h2 class="mb-3 text-[15px] font-semibold text-text">{{ t("user.returnShipping") }}</h2>
<VCard
v-if="detail.status === 'approved' && detail.kind === 'return_refund'"
:padded="false"
class="p-5"
>
<h2 class="text-text mb-3 text-[15px] font-semibold">{{ t("user.returnShipping") }}</h2>
<form class="grid max-w-[520px] gap-3" @submit.prevent="submitTracking">
<VField :label="t('user.aftersaleCarrier')"><VInput v-model="carrier" /></VField>
<VField :label="t('user.aftersaleTrackingNo')"><VInput v-model="trackingNo" /></VField>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{ t("user.submitReturnTracking") }}</VBtn>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{
t("user.submitReturnTracking")
}}</VBtn>
</form>
</VCard>
<VCard :padded="false" class="p-5">
<h2 class="mb-3 text-[15px] font-semibold text-text">{{ t("user.aftersaleMessages") }}</h2>
<div v-if="detail.messages.length === 0" class="mb-4 text-sm text-muted">{{ t("user.noAftersales") }}</div>
<h2 class="text-text mb-3 text-[15px] font-semibold">{{ t("user.aftersaleMessages") }}</h2>
<div v-if="detail.messages.length === 0" class="text-muted mb-4 text-sm">
{{ t("user.noAftersales") }}
</div>
<div v-else class="mb-4 space-y-2">
<div v-for="entry in detail.messages" :key="entry.id" class="rounded border border-border p-3" :class="entry.author_role === 'buyer' ? 'bg-bg' : 'bg-surface'">
<div class="flex items-center justify-between gap-2 text-xs text-muted"><span>{{ t(`user.${entry.author_role}`) }}</span><time>{{ entry.created_at.slice(0, 16).replace('T', ' ') }}</time></div>
<p class="m-0 mt-2 whitespace-pre-wrap text-sm text-text">{{ pick(entry.content, locale) }}</p>
<div v-if="entry.evidence.length" class="mt-2 flex flex-wrap gap-2 text-xs"><a v-for="url in entry.evidence" :key="url" class="text-primary" :href="url" target="_blank" rel="noreferrer">{{ t("user.aftersaleViewEvidence") }}</a></div>
<div
v-for="entry in detail.messages"
:key="entry.id"
class="border-border rounded border p-3"
:class="entry.author_role === 'buyer' ? 'bg-bg' : 'bg-surface'"
>
<div class="text-muted flex items-center justify-between gap-2 text-xs">
<span>{{ t(`user.${entry.author_role}`) }}</span
><time>{{ entry.created_at.slice(0, 16).replace("T", " ") }}</time>
</div>
<p class="text-text m-0 mt-2 text-sm whitespace-pre-wrap">
{{ pick(entry.content, locale) }}
</p>
<div v-if="entry.evidence.length" class="mt-2 flex flex-wrap gap-2 text-xs">
<a
v-for="url in entry.evidence"
:key="url"
class="text-primary"
:href="url"
target="_blank"
rel="noreferrer"
>{{ t("user.aftersaleViewEvidence") }}</a
>
</div>
</div>
</div>
<form class="grid max-w-[640px] gap-3" @submit.prevent="sendMessage">
<VField :label="t('user.aftersaleMessagePlaceholder')"><textarea v-model="message" class="min-h-24 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleMessagePlaceholder')" /></VField>
<VField :label="t('user.aftersaleEvidence')"><textarea v-model="evidence" class="min-h-16 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleEvidenceHint')" /></VField>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{ t("user.sendMessage") }}</VBtn>
<VField :label="t('user.aftersaleMessagePlaceholder')">
<textarea
v-model="message"
class="border-border bg-surface text-text min-h-24 rounded-md border p-2.5 text-sm"
:placeholder="t('user.aftersaleMessagePlaceholder')"
/>
</VField>
<VField :label="t('user.aftersaleEvidence')">
<textarea
v-model="evidence"
class="border-border bg-surface text-text min-h-16 rounded-md border p-2.5 text-sm"
:placeholder="t('user.aftersaleEvidenceHint')"
/>
</VField>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{
t("user.sendMessage")
}}</VBtn>
</form>
</VCard>
<p v-if="errorKey" class="m-0 text-xs text-danger">{{ t(errorKey) }}</p>
<p v-if="errorKey" class="text-danger m-0 text-xs">{{ t(errorKey) }}</p>
</div>
</VCard>
</template>
+51 -15
View File
@@ -33,7 +33,10 @@ async function load(): Promise<void> {
loading.value = true;
try {
await ensureCurrencies();
const [loadedOrder, loadedAftersales] = await Promise.all([$api.getOrder(orderId.value), $api.listMyAftersales()]);
const [loadedOrder, loadedAftersales] = await Promise.all([
$api.getOrder(orderId.value),
$api.listMyAftersales(),
]);
order.value = loadedOrder;
aftersales.value = loadedAftersales;
} catch {
@@ -70,7 +73,10 @@ async function submit(): Promise<void> {
kind: kind.value,
reason: { en: content, zh: content },
amount_minor: amount,
evidence: evidence.value.split("\n").map((url) => url.trim()).filter(Boolean),
evidence: evidence.value
.split("\n")
.map((url) => url.trim())
.filter(Boolean),
});
await navigateTo(`/user/aftersales/${created.id}`);
} catch {
@@ -85,26 +91,56 @@ onMounted(() => void load());
<template>
<VCard class="min-h-[560px]">
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.aftersaleApply") }}</h1>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<h1 class="text-text mb-4 text-xl font-bold">{{ t("user.aftersaleApply") }}</h1>
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
<form v-else class="grid max-w-[640px] gap-4" @submit.prevent="submit">
<VCard :padded="false" class="flex items-center gap-3 border border-border p-4">
<img class="h-14 w-14 border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="item.product_name.en" />
<div class="min-w-0 flex-1"><p class="m-0 truncate text-sm text-text">{{ item.product_name.en }}</p><p class="mt-1 text-xs text-muted">{{ item.sku_code }} × {{ item.qty }}</p></div>
<VCard :padded="false" class="border-border flex items-center gap-3 border p-4">
<img
class="border-border h-14 w-14 border object-contain"
:src="item.image || '/mock/product-1.svg'"
:alt="item.product_name.en"
/>
<div class="min-w-0 flex-1">
<p class="text-text m-0 truncate text-sm">{{ item.product_name.en }}</p>
<p class="text-muted mt-1 text-xs">{{ item.sku_code }} × {{ item.qty }}</p>
</div>
</VCard>
<fieldset class="grid gap-2 border-0 p-0">
<legend class="mb-1 block text-[13px] text-muted">{{ t("user.aftersaleType") }}</legend>
<label class="text-sm text-text"><input v-model="kind" class="mr-1 accent-primary" type="radio" value="refund_only" /> {{ t("user.refundOnly") }}</label>
<label class="text-sm text-text"><input v-model="kind" class="mr-1 accent-primary" type="radio" value="return_refund" /> {{ t("user.returnRefund") }}</label>
<legend class="text-muted mb-1 block text-[13px]">{{ t("user.aftersaleType") }}</legend>
<label class="text-text text-sm"
><input v-model="kind" class="accent-primary mr-1" type="radio" value="refund_only" />
{{ t("user.refundOnly") }}</label
>
<label class="text-text text-sm"
><input v-model="kind" class="accent-primary mr-1" type="radio" value="return_refund" />
{{ t("user.returnRefund") }}</label
>
</fieldset>
<VField :label="t('user.aftersaleReason')"><textarea v-model="reason" class="min-h-24 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleReasonHint')" /></VField>
<VField :label="t('user.aftersaleReason')">
<textarea
v-model="reason"
class="border-border bg-surface text-text min-h-24 rounded-md border p-2.5 text-sm"
:placeholder="t('user.aftersaleReasonHint')"
/>
</VField>
<VField :label="t('user.aftersaleAmount')">
<VInput v-model="amountMajor" />
<p class="m-0 mt-1 text-xs text-muted">{{ t("user.aftersaleAmountHint") }} {{ t("user.aftersaleRemaining") }}: <PriceText :amount-minor="remainingMinor" :currency="order.currency" /></p>
<p class="text-muted m-0 mt-1 text-xs">
{{ t("user.aftersaleAmountHint") }} {{ t("user.aftersaleRemaining") }}:
<PriceText :amount-minor="remainingMinor" :currency="order.currency" />
</p>
</VField>
<VField :label="t('user.aftersaleEvidence')"><textarea v-model="evidence" class="min-h-20 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleEvidenceHint')" /></VField>
<p v-if="errorKey" class="m-0 text-xs text-danger">{{ t(errorKey) }}</p>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{ t("user.submitAftersale") }}</VBtn>
<VField :label="t('user.aftersaleEvidence')">
<textarea
v-model="evidence"
class="border-border bg-surface text-text min-h-20 rounded-md border p-2.5 text-sm"
:placeholder="t('user.aftersaleEvidenceHint')"
/>
</VField>
<p v-if="errorKey" class="text-danger m-0 text-xs">{{ t(errorKey) }}</p>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{
t("user.submitAftersale")
}}</VBtn>
</form>
</VCard>
</template>
+17 -7
View File
@@ -39,11 +39,13 @@ onMounted(() => void load());
<template>
<VCard class="min-h-[560px]">
<div class="mb-4 flex items-center justify-between gap-3">
<h1 class="m-0 text-xl font-bold text-text">{{ t("user.aftersalesTitle") }}</h1>
<NuxtLink class="text-sm text-primary no-underline" to="/user/orders">{{ t("user.myOrders") }}</NuxtLink>
<h1 class="text-text m-0 text-xl font-bold">{{ t("user.aftersalesTitle") }}</h1>
<NuxtLink class="text-primary text-sm no-underline" to="/user/orders">{{
t("user.myOrders")
}}</NuxtLink>
</div>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<p v-else-if="failed" class="py-5 text-sm text-danger">{{ t("user.aftersaleLoadFailed") }}</p>
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
<p v-else-if="failed" class="text-danger py-5 text-sm">{{ t("user.aftersaleLoadFailed") }}</p>
<UiEmptyState v-else-if="rows.length === 0" :text="t('user.noAftersales')" />
<VTable v-else>
<thead>
@@ -59,13 +61,21 @@ onMounted(() => void load());
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>
<NuxtLink class="text-primary no-underline" :to="`/user/aftersales/${row.id}`">{{ row.order_id }}</NuxtLink>
<NuxtLink class="text-primary no-underline" :to="`/user/aftersales/${row.id}`">{{
row.order_id
}}</NuxtLink>
</td>
<td>{{ t(row.kind === "refund_only" ? "user.refundOnly" : "user.returnRefund") }}</td>
<td><PriceText :amount-minor="row.amount_minor" :currency="row.currency" /></td>
<td><VBadge :tone="tone(row.status)">{{ statusLabel(row.status) }}</VBadge></td>
<td>
<VBadge :tone="tone(row.status)">{{ statusLabel(row.status) }}</VBadge>
</td>
<td class="text-muted">{{ row.created_at.slice(0, 10) }}</td>
<td><NuxtLink class="text-primary no-underline" :to="`/user/aftersales/${row.id}`">{{ t("user.viewDetails") }}</NuxtLink></td>
<td>
<NuxtLink class="text-primary no-underline" :to="`/user/aftersales/${row.id}`">{{
t("user.viewDetails")
}}</NuxtLink>
</td>
</tr>
</tbody>
</VTable>
+5 -4
View File
@@ -33,9 +33,11 @@ onMounted(() => void load());
<template>
<VCard class="min-h-[560px]">
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.couponsTitle") }}</h1>
<p v-if="error" class="mb-4 border border-danger/30 bg-danger/10 px-3.5 py-2.5 text-danger">{{ error }}</p>
<div v-else-if="loading" class="py-10 text-muted">{{ t("common.loading") }}</div>
<h1 class="text-text mb-4 text-xl font-bold">{{ t("user.couponsTitle") }}</h1>
<p v-if="error" class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5">
{{ error }}
</p>
<div v-else-if="loading" class="text-muted py-10">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="coupons.length === 0" :text="t('user.noCoupons')" />
<VTable v-else>
<thead>
@@ -59,4 +61,3 @@ onMounted(() => void load());
</VTable>
</VCard>
</template>
+84 -23
View File
@@ -41,13 +41,21 @@ async function load(): Promise<Paged<Favorite> | null> {
page: requestedPage,
per_page: perPage,
});
if (version !== loadVersion || activeTab.value !== requestedKind || page.value !== requestedPage) {
if (
version !== loadVersion ||
activeTab.value !== requestedKind ||
page.value !== requestedPage
) {
return null;
}
result.value = next;
return next;
} catch {
if (version === loadVersion && activeTab.value === requestedKind && page.value === requestedPage) {
if (
version === loadVersion &&
activeTab.value === requestedKind &&
page.value === requestedPage
) {
error.value = t("user.favoriteLoadFailed");
result.value = { ...emptyPage };
}
@@ -61,9 +69,13 @@ watch(activeTab, () => {
page.value = 1;
});
watch([activeTab, page], () => {
void load();
}, { immediate: true });
watch(
[activeTab, page],
() => {
void load();
},
{ immediate: true },
);
async function removeProductFavorite(productId: string): Promise<void> {
if (removingId.value) return;
@@ -96,22 +108,49 @@ async function removeStoreFavorite(storeId: string): Promise<void> {
<template>
<VCard class="min-h-[560px]">
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.favoritesTitle") }}</h1>
<p v-if="error" class="mb-4 text-sm text-muted">{{ error }}</p>
<h1 class="text-text mb-4 text-xl font-bold">{{ t("user.favoritesTitle") }}</h1>
<p v-if="error" class="text-muted mb-4 text-sm">{{ error }}</p>
<UiTabs v-model="activeTab" :tabs="tabs">
<template #default>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
<div v-else-if="activeTab === 'product'">
<UiEmptyState v-if="productRows.length === 0" :text="t('user.noFavorites')" />
<div v-else class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(220px,1fr))]">
<VCard v-for="item in productRows" :key="item.id" :padded="false" class="overflow-hidden transition-shadow hover:shadow-md">
<NuxtLink :to="`/goods/${item.product.slug}`" class="block border-b border-border">
<img class="block h-[150px] w-full object-contain" :src="item.product.image || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
<div
v-else
class="grid [grid-template-columns:repeat(auto-fill,minmax(220px,1fr))] gap-3"
>
<VCard
v-for="item in productRows"
:key="item.id"
:padded="false"
class="overflow-hidden transition-shadow hover:shadow-md"
>
<NuxtLink :to="`/goods/${item.product.slug}`" class="border-border block border-b">
<img
class="block h-[150px] w-full object-contain"
:src="item.product.image || '/mock/product-1.svg'"
:alt="pick(item.product.name, locale)"
/>
</NuxtLink>
<div class="p-2.5">
<NuxtLink :to="`/goods/${item.product.slug}`" class="mb-2 block h-9 overflow-hidden text-[13px] leading-5 text-text no-underline hover:text-primary">{{ pick(item.product.name, locale) }}</NuxtLink>
<PriceText v-if="item.product.price_minor !== null && item.product.currency" class="mb-2.5 block text-primary" :amount-minor="item.product.price_minor" :currency="item.product.currency" />
<VBtn size="sm" type="button" :disabled="removingId === item.product.id" @click="removeProductFavorite(item.product.id)">{{ t("user.removeFavorite") }}</VBtn>
<NuxtLink
:to="`/goods/${item.product.slug}`"
class="text-text hover:text-primary mb-2 block h-9 overflow-hidden text-[13px] leading-5 no-underline"
>{{ pick(item.product.name, locale) }}</NuxtLink
>
<PriceText
v-if="item.product.price_minor !== null && item.product.currency"
class="text-primary mb-2.5 block"
:amount-minor="item.product.price_minor"
:currency="item.product.currency"
/>
<VBtn
size="sm"
type="button"
:disabled="removingId === item.product.id"
@click="removeProductFavorite(item.product.id)"
>{{ t("user.removeFavorite") }}</VBtn
>
</div>
</VCard>
</div>
@@ -120,16 +159,39 @@ async function removeStoreFavorite(storeId: string): Promise<void> {
<div v-else>
<UiEmptyState v-if="storeRows.length === 0" :text="t('user.noFavoriteStores')" />
<div v-else class="space-y-2.5">
<article v-for="item in storeRows" :key="item.id" class="flex items-center gap-3.5 border border-border p-3 max-sm:flex-col max-sm:items-start">
<img v-if="item.shop.logo" class="h-14 w-14 rounded-sm border border-border object-cover" :src="item.shop.logo" :alt="pick(item.shop.name, locale)" />
<span v-else class="flex h-14 w-14 items-center justify-center rounded-sm border border-border bg-bg text-lg text-muted">{{ pick(item.shop.name, locale).slice(0, 1) }}</span>
<article
v-for="item in storeRows"
:key="item.id"
class="border-border flex items-center gap-3.5 border p-3 max-sm:flex-col max-sm:items-start"
>
<img
v-if="item.shop.logo"
class="border-border h-14 w-14 rounded-sm border object-cover"
:src="item.shop.logo"
:alt="pick(item.shop.name, locale)"
/>
<span
v-else
class="border-border bg-bg text-muted flex h-14 w-14 items-center justify-center rounded-sm border text-lg"
>{{ pick(item.shop.name, locale).slice(0, 1) }}</span
>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<strong class="text-sm text-text">{{ pick(item.shop.name, locale) }}</strong>
<span class="text-xs text-muted">{{ item.shop.company }}</span>
<strong class="text-text text-sm">{{ pick(item.shop.name, locale) }}</strong>
<span class="text-muted text-xs">{{ item.shop.company }}</span>
</div>
<div class="flex gap-2 max-sm:w-full">
<NuxtLink class="inline-flex items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[13px] font-medium text-text no-underline" :to="`/stores/${item.shop.slug}`">{{ t("user.enterStore") }}</NuxtLink>
<VBtn size="sm" type="button" :disabled="removingId === item.shop.id" @click="removeStoreFavorite(item.shop.id)">{{ t("user.removeFavorite") }}</VBtn>
<NuxtLink
class="border-border bg-surface text-text inline-flex items-center rounded-md border px-2.5 py-1 text-[13px] font-medium no-underline"
:to="`/stores/${item.shop.slug}`"
>{{ t("user.enterStore") }}</NuxtLink
>
<VBtn
size="sm"
type="button"
:disabled="removingId === item.shop.id"
@click="removeStoreFavorite(item.shop.id)"
>{{ t("user.removeFavorite") }}</VBtn
>
</div>
</article>
</div>
@@ -145,4 +207,3 @@ async function removeStoreFavorite(storeId: string): Promise<void> {
</UiTabs>
</VCard>
</template>
+128 -25
View File
@@ -59,56 +59,159 @@ const counts = computed(() => ({
}));
const statusLinks = computed(() => [
{ key: "pendingPayment", label: t("user.pendingPayment"), count: counts.value.pendingPayment, to: "/user/orders?status=pending_payment" },
{ key: "pendingShipment", label: t("user.pendingShipment"), count: counts.value.pendingShipment, to: "/user/orders?status=paid" },
{ key: "pendingReceipt", label: t("user.pendingReceipt"), count: counts.value.pendingReceipt, to: "/user/orders?status=shipped" },
{ key: "completed", label: t("user.completed"), count: counts.value.completed, to: "/user/orders?status=completed" },
{ key: "afterSale", label: t("user.afterSale"), count: counts.value.afterSale, to: "/user/orders?status=after-sale" },
{
key: "pendingPayment",
label: t("user.pendingPayment"),
count: counts.value.pendingPayment,
to: "/user/orders?status=pending_payment",
},
{
key: "pendingShipment",
label: t("user.pendingShipment"),
count: counts.value.pendingShipment,
to: "/user/orders?status=paid",
},
{
key: "pendingReceipt",
label: t("user.pendingReceipt"),
count: counts.value.pendingReceipt,
to: "/user/orders?status=shipped",
},
{
key: "completed",
label: t("user.completed"),
count: counts.value.completed,
to: "/user/orders?status=completed",
},
{
key: "afterSale",
label: t("user.afterSale"),
count: counts.value.afterSale,
to: "/user/orders?status=after-sale",
},
]);
</script>
<template>
<div class="space-y-4">
<VCard class="pb-0">
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.dashboard") }}</h1>
<h1 class="text-text mb-4 text-xl font-bold">{{ t("user.dashboard") }}</h1>
<div class="mb-5 grid gap-3 sm:grid-cols-3">
<div class="border border-border bg-primary-soft p-[18px]"><span class="block text-xs text-muted">{{ t("user.statsBalance") }}</span><strong class="mt-2.5 block text-xl text-primary"><PriceText v-if="stats" :amount-minor="stats.balance_minor" :currency="stats.currency" /><template v-else>{{ t("common.loading") }}</template></strong></div>
<div class="border border-border bg-primary-soft p-[18px]"><span class="block text-xs text-muted">{{ t("user.statsPoints") }}</span><strong class="mt-2.5 block text-xl text-primary">{{ stats ? stats.points : t("common.loading") }}</strong></div>
<div class="border border-border bg-primary-soft p-[18px]"><span class="block text-xs text-muted">{{ t("user.statsFrozen") }}</span><strong class="mt-2.5 block text-xl text-primary"><PriceText v-if="stats" :amount-minor="stats.frozen_minor" :currency="stats.currency" /><template v-else>{{ t("common.loading") }}</template></strong></div>
<div class="border-border bg-primary-soft border p-[18px]">
<span class="text-muted block text-xs">{{ t("user.statsBalance") }}</span
><strong class="text-primary mt-2.5 block text-xl"
><PriceText
v-if="stats"
:amount-minor="stats.balance_minor"
:currency="stats.currency"
/><template v-else>{{ t("common.loading") }}</template></strong
>
</div>
<div class="border-border bg-primary-soft border p-[18px]">
<span class="text-muted block text-xs">{{ t("user.statsPoints") }}</span
><strong class="text-primary mt-2.5 block text-xl">{{
stats ? stats.points : t("common.loading")
}}</strong>
</div>
<div class="border-border bg-primary-soft border p-[18px]">
<span class="text-muted block text-xs">{{ t("user.statsFrozen") }}</span
><strong class="text-primary mt-2.5 block text-xl"
><PriceText
v-if="stats"
:amount-minor="stats.frozen_minor"
:currency="stats.currency"
/><template v-else>{{ t("common.loading") }}</template></strong
>
</div>
</div>
<div class="grid grid-cols-3 border-t border-border sm:grid-cols-5">
<NuxtLink v-for="item in statusLinks" :key="item.key" :to="item.to" class="flex flex-col items-center gap-2 px-2 py-4 text-xs text-muted no-underline hover:text-primary sm:border-l sm:border-border first:sm:border-l-0"><span>{{ item.label }}</span><b class="text-lg font-semibold text-primary">{{ item.count }}</b></NuxtLink>
<div class="border-border grid grid-cols-3 border-t sm:grid-cols-5">
<NuxtLink
v-for="item in statusLinks"
:key="item.key"
:to="item.to"
class="text-muted hover:text-primary sm:border-border flex flex-col items-center gap-2 px-2 py-4 text-xs no-underline sm:border-l first:sm:border-l-0"
><span>{{ item.label }}</span
><b class="text-primary text-lg font-semibold">{{ item.count }}</b></NuxtLink
>
</div>
</VCard>
<VCard>
<h2 class="mb-4 flex items-center justify-between text-lg font-semibold text-text">{{ t("user.recentOrders") }} <NuxtLink class="text-sm font-normal text-primary no-underline" to="/user/orders">{{ t("user.viewAll") }} ›</NuxtLink></h2>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<h2 class="text-text mb-4 flex items-center justify-between text-lg font-semibold">
{{ t("user.recentOrders") }}
<NuxtLink class="text-primary text-sm font-normal no-underline" to="/user/orders"
>{{ t("user.viewAll") }} ›</NuxtLink
>
</h2>
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="orders.length === 0" :text="t('user.noOrders')" />
<div v-else class="space-y-3">
<article v-for="order in orders.slice(0, 3)" :key="order.id" class="border border-border">
<header class="flex items-center justify-between gap-3 bg-bg px-3 py-2.5 text-xs text-muted"><span>{{ t("user.orderNo") }} {{ order.order_no }}</span><StatusBadge :status="order.status" kind="order" /></header>
<article v-for="order in orders.slice(0, 3)" :key="order.id" class="border-border border">
<header
class="bg-bg text-muted flex items-center justify-between gap-3 px-3 py-2.5 text-xs"
>
<span>{{ t("user.orderNo") }} {{ order.order_no }}</span
><StatusBadge :status="order.status" kind="order" />
</header>
<div class="flex items-center gap-4 p-3 max-sm:flex-wrap">
<div class="flex min-w-[140px] gap-1"><img v-for="item in order.items" :key="item.id" class="h-11 w-11 border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" /></div>
<div class="flex-1 text-xs text-muted">{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</div>
<div class="text-right text-xs"><span class="mb-1 block text-muted">{{ t("user.orderTotal") }}</span><PriceText class="font-semibold text-primary" :amount-minor="order.total_minor" :currency="order.currency" /></div>
<NuxtLink class="inline-flex items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[13px] font-medium text-text no-underline" :to="`/user/orders/${order.id}`">{{ t("user.viewDetails") }}</NuxtLink>
<div class="flex min-w-[140px] gap-1">
<img
v-for="item in order.items"
:key="item.id"
class="border-border h-11 w-11 border object-contain"
:src="item.image || '/mock/product-1.svg'"
:alt="pick(item.product_name, locale)"
/>
</div>
<div class="text-muted flex-1 text-xs">
{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}
</div>
<div class="text-right text-xs">
<span class="text-muted mb-1 block">{{ t("user.orderTotal") }}</span
><PriceText
class="text-primary font-semibold"
:amount-minor="order.total_minor"
:currency="order.currency"
/>
</div>
<NuxtLink
class="border-border bg-surface text-text inline-flex items-center rounded-md border px-2.5 py-1 text-[13px] font-medium no-underline"
:to="`/user/orders/${order.id}`"
>{{ t("user.viewDetails") }}</NuxtLink
>
</div>
</article>
</div>
</VCard>
<VCard>
<h2 class="mb-4 text-lg font-semibold text-text">{{ t("user.favoriteProducts") }} ({{ favoriteProductTotal }})</h2>
<h2 class="text-text mb-4 text-lg font-semibold">
{{ t("user.favoriteProducts") }} ({{ favoriteProductTotal }})
</h2>
<UiEmptyState v-if="favoriteProducts.length === 0" :text="t('user.noFavorites')" />
<div v-else class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<NuxtLink v-for="item in favoriteProducts" :key="item.id" :to="`/goods/${item.product.slug}`" class="block border border-border p-2.5 text-text no-underline hover:border-primary">
<img class="mb-2 block h-[120px] w-full object-contain" :src="item.product.image || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
<span class="block h-[34px] overflow-hidden text-xs leading-[17px]">{{ pick(item.product.name, locale) }}</span>
<PriceText v-if="item.product.price_minor !== null && item.product.currency" class="mt-2 block text-sm font-semibold text-primary" :amount-minor="item.product.price_minor" :currency="item.product.currency" />
<NuxtLink
v-for="item in favoriteProducts"
:key="item.id"
:to="`/goods/${item.product.slug}`"
class="border-border text-text hover:border-primary block border p-2.5 no-underline"
>
<img
class="mb-2 block h-[120px] w-full object-contain"
:src="item.product.image || '/mock/product-1.svg'"
:alt="pick(item.product.name, locale)"
/>
<span class="block h-[34px] overflow-hidden text-xs leading-[17px]">{{
pick(item.product.name, locale)
}}</span>
<PriceText
v-if="item.product.price_minor !== null && item.product.currency"
class="text-primary mt-2 block text-sm font-semibold"
:amount-minor="item.product.price_minor"
:currency="item.product.currency"
/>
</NuxtLink>
</div>
</VCard>
</div>
</template>
+2 -3
View File
@@ -25,8 +25,8 @@ onMounted(() => {
<template>
<VCard class="min-h-[560px]">
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.invoicesTitle") }}</h1>
<div v-if="loading" class="px-0 py-5 text-muted">{{ t("common.loading") }}</div>
<h1 class="text-text mb-4 text-xl font-bold">{{ t("user.invoicesTitle") }}</h1>
<div v-if="loading" class="text-muted px-0 py-5">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="rows.length === 0" :text="t('user.noInvoices')" />
<VTable v-else>
<thead>
@@ -54,4 +54,3 @@ onMounted(() => {
</VTable>
</VCard>
</template>
+151 -33
View File
@@ -23,21 +23,36 @@ const invoiceKind = ref<InvoiceKind>("personal");
const orderId = computed(() => String(route.params.id ?? ""));
const orderShipments = computed(() => shipments.value.filter((shipment) => shipment.order_id === order.value?.id));
const activeAftersaleItemIds = computed(() => new Set(
aftersales.value
.filter((row) => ["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status))
.map((row) => row.order_item_id),
));
const orderShipments = computed(() =>
shipments.value.filter((shipment) => shipment.order_id === order.value?.id),
);
const activeAftersaleItemIds = computed(
() =>
new Set(
aftersales.value
.filter((row) =>
["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status),
)
.map((row) => row.order_item_id),
),
);
function canApply(itemId: string): boolean {
return Boolean(order.value && ["paid", "fulfilling", "shipped", "completed"].includes(order.value.status) && !activeAftersaleItemIds.value.has(itemId));
return Boolean(
order.value &&
["paid", "fulfilling", "shipped", "completed"].includes(order.value.status) &&
!activeAftersaleItemIds.value.has(itemId),
);
}
async function load(): Promise<void> {
loading.value = true;
try {
const [o, allShipments, mine] = await Promise.all([$api.getOrder(orderId.value), $api.listMyShipments(), $api.listMyAftersales()]);
const [o, allShipments, mine] = await Promise.all([
$api.getOrder(orderId.value),
$api.listMyShipments(),
$api.listMyAftersales(),
]);
order.value = o;
shipments.value = allShipments;
aftersales.value = mine;
@@ -85,62 +100,165 @@ onMounted(() => {
<template>
<VCard class="min-h-[560px]">
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.orderDetail") }}</h1>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<h1 class="text-text mb-4 text-xl font-bold">{{ t("user.orderDetail") }}</h1>
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="!order" :text="t('user.noOrders')" />
<div v-else class="space-y-4">
<header class="flex items-center justify-between border border-border bg-bg p-3.5 max-sm:flex-col max-sm:items-start max-sm:gap-2.5">
<div><strong class="text-[15px] text-text">{{ t("user.orderNo") }} {{ order.order_no }}</strong><p class="mt-1 text-xs text-muted">{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</p></div>
<header
class="border-border bg-bg flex items-center justify-between border p-3.5 max-sm:flex-col max-sm:items-start max-sm:gap-2.5"
>
<div>
<strong class="text-text text-[15px]"
>{{ t("user.orderNo") }} {{ order.order_no }}</strong
>
<p class="text-muted mt-1 text-xs">
{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}
</p>
</div>
<StatusBadge :status="order.status" kind="order" />
</header>
<VCard :padded="false" class="p-5">
<h2 class="mb-3.5 text-[15px] font-semibold text-text">{{ t("user.shippingAddress") }}</h2>
<p class="m-0 text-sm text-text">{{ order.shipping_address.recipient }} · {{ order.shipping_address.phone }}</p>
<p class="mt-1 text-sm text-text">{{ order.shipping_address.country }} {{ order.shipping_address.region }} {{ order.shipping_address.city }} {{ order.shipping_address.line1 }} {{ order.shipping_address.postal_code }}</p>
<h2 class="text-text mb-3.5 text-[15px] font-semibold">{{ t("user.shippingAddress") }}</h2>
<p class="text-text m-0 text-sm">
{{ order.shipping_address.recipient }} · {{ order.shipping_address.phone }}
</p>
<p class="text-text mt-1 text-sm">
{{ order.shipping_address.country }} {{ order.shipping_address.region }}
{{ order.shipping_address.city }} {{ order.shipping_address.line1 }}
{{ order.shipping_address.postal_code }}
</p>
</VCard>
<VCard :padded="false" class="p-5">
<h2 class="mb-3.5 text-[15px] font-semibold text-text">{{ t("user.orderItems") }}</h2>
<h2 class="text-text mb-3.5 text-[15px] font-semibold">{{ t("user.orderItems") }}</h2>
<VTable>
<thead><tr><th>{{ t("user.product") }}</th><th>{{ t("common.price") }}</th><th>{{ t("common.qty") }}</th><th>{{ t("common.total") }}</th><th /></tr></thead>
<thead>
<tr>
<th>{{ t("user.product") }}</th>
<th>{{ t("common.price") }}</th>
<th>{{ t("common.qty") }}</th>
<th>{{ t("common.total") }}</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="item in order.items" :key="item.id">
<td><div class="flex items-center gap-2.5"><img class="h-11 w-11 border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" /><span>{{ pick(item.product_name, locale) }}</span><VBadge v-if="item.flash_sale_item_id" tone="red">{{ t("marketing.flashTag") }}</VBadge></div></td>
<td><PriceText :amount-minor="item.unit_price_minor" :currency="order.currency" /></td>
<td>
<div class="flex items-center gap-2.5">
<img
class="border-border h-11 w-11 border object-contain"
:src="item.image || '/mock/product-1.svg'"
:alt="pick(item.product_name, locale)"
/><span>{{ pick(item.product_name, locale) }}</span
><VBadge v-if="item.flash_sale_item_id" tone="red">{{
t("marketing.flashTag")
}}</VBadge>
</div>
</td>
<td>
<PriceText :amount-minor="item.unit_price_minor" :currency="order.currency" />
</td>
<td>{{ item.qty }}</td>
<td><PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="order.currency" /></td>
<td><NuxtLink v-if="canApply(item.id)" class="text-primary no-underline" :to="{ path: '/user/aftersales/apply', query: { order_id: order.id, item_id: item.id } }">{{ t("user.applyAftersaleForItem") }}</NuxtLink></td>
<td>
<PriceText
:amount-minor="item.unit_price_minor * item.qty"
:currency="order.currency"
/>
</td>
<td>
<NuxtLink
v-if="canApply(item.id)"
class="text-primary no-underline"
:to="{
path: '/user/aftersales/apply',
query: { order_id: order.id, item_id: item.id },
}"
>{{ t("user.applyAftersaleForItem") }}</NuxtLink
>
</td>
</tr>
</tbody>
</VTable>
<p v-if="order.discount_minor > 0" class="mt-4 text-right text-[13px] text-primary">{{ t("checkout.couponDiscount") }}: <PriceText :amount-minor="order.discount_minor" :currency="order.currency" /></p>
<p class="mt-4 text-right text-[13px] text-muted">{{ t("user.total") }}: <PriceText class="text-lg font-semibold text-primary" :amount-minor="order.total_minor" :currency="order.currency" /></p>
<p v-if="order.discount_minor > 0" class="text-primary mt-4 text-right text-[13px]">
{{ t("checkout.couponDiscount") }}:
<PriceText :amount-minor="order.discount_minor" :currency="order.currency" />
</p>
<p class="text-muted mt-4 text-right text-[13px]">
{{ t("user.total") }}:
<PriceText
class="text-primary text-lg font-semibold"
:amount-minor="order.total_minor"
:currency="order.currency"
/>
</p>
</VCard>
<VCard :padded="false" class="p-5">
<h2 class="mb-3.5 text-[15px] font-semibold text-text">{{ t("user.shipments") }}</h2>
<h2 class="text-text mb-3.5 text-[15px] font-semibold">{{ t("user.shipments") }}</h2>
<UiEmptyState v-if="orderShipments.length === 0" :text="t('user.noShipments')" />
<div v-else class="space-y-2.5">
<div v-for="shipment in orderShipments" :key="shipment.id" class="flex items-center justify-between border border-border p-3 max-sm:flex-col max-sm:items-start max-sm:gap-2.5">
<div><strong class="text-sm text-text">{{ t("user.shipmentNo") }} {{ shipment.shipment_no }}</strong><p class="mt-1 text-xs text-muted">{{ t("user.carrier") }}: {{ shipment.carrier }}</p><p class="mt-1 text-xs text-muted">{{ t("user.trackingNo") }}: {{ shipment.tracking_no }}</p></div>
<div class="flex items-center gap-2"><StatusBadge :status="shipment.status" kind="shipment" /><VBtn v-if="shipment.status === 'shipped'" variant="primary" size="sm" type="button" :disabled="confirmingShipmentId === shipment.id" @click="confirmDelivery(shipment)">{{ t("user.confirmDelivery") }}</VBtn></div>
<div
v-for="shipment in orderShipments"
:key="shipment.id"
class="border-border flex items-center justify-between border p-3 max-sm:flex-col max-sm:items-start max-sm:gap-2.5"
>
<div>
<strong class="text-text text-sm"
>{{ t("user.shipmentNo") }} {{ shipment.shipment_no }}</strong
>
<p class="text-muted mt-1 text-xs">{{ t("user.carrier") }}: {{ shipment.carrier }}</p>
<p class="text-muted mt-1 text-xs">
{{ t("user.trackingNo") }}: {{ shipment.tracking_no }}
</p>
</div>
<div class="flex items-center gap-2">
<StatusBadge :status="shipment.status" kind="shipment" /><VBtn
v-if="shipment.status === 'shipped'"
variant="primary"
size="sm"
type="button"
:disabled="confirmingShipmentId === shipment.id"
@click="confirmDelivery(shipment)"
>{{ t("user.confirmDelivery") }}</VBtn
>
</div>
</div>
</div>
</VCard>
<VCard :padded="false" class="p-5">
<h2 class="mb-3.5 text-[15px] font-semibold text-text">{{ t("user.invoiceRequest") }}</h2>
<div v-if="invoiceSuccess" class="text-sm text-success">{{ t("user.invoiceSuccess") }}</div>
<h2 class="text-text mb-3.5 text-[15px] font-semibold">{{ t("user.invoiceRequest") }}</h2>
<div v-if="invoiceSuccess" class="text-success text-sm">{{ t("user.invoiceSuccess") }}</div>
<form v-else class="grid max-w-[520px] gap-3" @submit.prevent="submitInvoice">
<VField :label="t('user.invoiceTitle')"><VInput v-model="invoiceTitle" /></VField>
<VField :label="t('user.taxNo')"><VInput v-model="invoiceTaxNo" /></VField>
<fieldset class="flex items-center gap-4 border-0 p-0"><legend class="mb-1 block text-[13px] text-muted">{{ t("user.invoiceKind") }}</legend><label class="text-[13px] text-text"><input v-model="invoiceKind" class="mr-1 accent-primary" type="radio" value="personal" /> {{ t("user.personal") }}</label><label class="text-[13px] text-text"><input v-model="invoiceKind" class="mr-1 accent-primary" type="radio" value="company" /> {{ t("user.company") }}</label></fieldset>
<p v-if="invoiceErrorKey" class="m-0 text-xs text-danger">{{ t(invoiceErrorKey) }}</p>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="invoiceWorking">{{ t("user.submitInvoice") }}</VBtn>
<fieldset class="flex items-center gap-4 border-0 p-0">
<legend class="text-muted mb-1 block text-[13px]">{{ t("user.invoiceKind") }}</legend>
<label class="text-text text-[13px]"
><input
v-model="invoiceKind"
class="accent-primary mr-1"
type="radio"
value="personal"
/>
{{ t("user.personal") }}</label
><label class="text-text text-[13px]"
><input
v-model="invoiceKind"
class="accent-primary mr-1"
type="radio"
value="company"
/>
{{ t("user.company") }}</label
>
</fieldset>
<p v-if="invoiceErrorKey" class="text-danger m-0 text-xs">{{ t(invoiceErrorKey) }}</p>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="invoiceWorking">{{
t("user.submitInvoice")
}}</VBtn>
</form>
</VCard>
</div>
</VCard>
</template>
+88 -24
View File
@@ -16,7 +16,8 @@ function shopName(shopId: string): string {
return shop ? pick(shop.name, locale.value) : t("user.shop");
}
const activeFilter = ref("all");const orders = ref<Order[]>([]);
const activeFilter = ref("all");
const orders = ref<Order[]>([]);
const page = ref(1);
const total = ref(0);
const perPage = ref(10);
@@ -43,18 +44,23 @@ const tabs = computed(() => [
const filteredOrders = computed(() => {
const statuses = filterStatuses[activeFilter.value] ?? [];
return statuses.length === 0 ? orders.value : orders.value.filter((order) => statuses.includes(order.status));
return statuses.length === 0
? orders.value
: orders.value.filter((order) => statuses.includes(order.status));
});
function setInitialFilter(): void {
const queryStatus = String(route.query.status ?? "");
const mapped = queryStatus === "paid" || queryStatus === "fulfilling"
? "pending_shipment"
: queryStatus === "shipped"
? "pending_receipt"
: queryStatus === "pending_payment" || queryStatus === "completed" || queryStatus === "cancelled"
? queryStatus
: "all";
const mapped =
queryStatus === "paid" || queryStatus === "fulfilling"
? "pending_shipment"
: queryStatus === "shipped"
? "pending_receipt"
: queryStatus === "pending_payment" ||
queryStatus === "completed" ||
queryStatus === "cancelled"
? queryStatus
: "all";
activeFilter.value = mapped;
}
@@ -103,30 +109,89 @@ onMounted(() => {
<template>
<VCard class="min-h-[560px]">
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.ordersTitle") }}</h1>
<h1 class="text-text mb-4 text-xl font-bold">{{ t("user.ordersTitle") }}</h1>
<UiTabs :model-value="activeFilter" :tabs="tabs" @update:model-value="changeFilter">
<template #default>
<div v-if="loading" class="py-[30px] text-muted">{{ t("common.loading") }}</div>
<div v-if="loading" class="text-muted py-[30px]">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="filteredOrders.length === 0" :text="t('user.noOrders')" />
<div v-else class="space-y-3.5">
<VCard v-for="order in filteredOrders" :key="order.id" :padded="false" class="overflow-hidden">
<header class="flex items-center justify-between gap-2.5 border-b border-border bg-bg px-3.5 py-3 text-xs text-muted max-sm:items-start">
<div class="flex min-w-0 items-center gap-3.5 max-sm:flex-col max-sm:items-start max-sm:gap-1"><strong class="text-[13px] font-semibold text-text">{{ shopName(order.shop_id) }}</strong><span>{{ t("user.orderNo") }} {{ order.order_no }}</span><span>{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</span></div>
<VCard
v-for="order in filteredOrders"
:key="order.id"
:padded="false"
class="overflow-hidden"
>
<header
class="border-border bg-bg text-muted flex items-center justify-between gap-2.5 border-b px-3.5 py-3 text-xs max-sm:items-start"
>
<div
class="flex min-w-0 items-center gap-3.5 max-sm:flex-col max-sm:items-start max-sm:gap-1"
>
<strong class="text-text text-[13px] font-semibold">{{
shopName(order.shop_id)
}}</strong
><span>{{ t("user.orderNo") }} {{ order.order_no }}</span
><span>{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</span>
</div>
<StatusBadge :status="order.status" kind="order" />
</header>
<div class="px-3.5 py-1">
<div v-for="item in order.items" :key="item.id" class="flex items-center gap-3 border-b border-border py-2.5 last:border-b-0">
<img class="h-[54px] w-[54px] border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
<div class="flex min-w-0 flex-1 flex-col gap-1 text-[13px]"><span class="truncate">{{ pick(item.product_name, locale) }}</span><small class="text-[11px] text-muted">{{ item.sku_code }} × {{ item.qty }}</small></div>
<PriceText class="text-[13px] text-muted" :amount-minor="item.unit_price_minor" :currency="order.currency" />
<div
v-for="item in order.items"
:key="item.id"
class="border-border flex items-center gap-3 border-b py-2.5 last:border-b-0"
>
<img
class="border-border h-[54px] w-[54px] border object-contain"
:src="item.image || '/mock/product-1.svg'"
:alt="pick(item.product_name, locale)"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1 text-[13px]">
<span class="truncate">{{ pick(item.product_name, locale) }}</span
><small class="text-muted text-[11px]"
>{{ item.sku_code }} × {{ item.qty }}</small
>
</div>
<PriceText
class="text-muted text-[13px]"
:amount-minor="item.unit_price_minor"
:currency="order.currency"
/>
</div>
</div>
<footer class="flex items-center justify-between gap-3 border-t border-border px-3.5 py-3 text-xs text-muted max-sm:flex-col max-sm:items-start">
<span>{{ t("user.orderTotal") }} <strong class="text-primary"><PriceText :amount-minor="order.total_minor" :currency="order.currency" /></strong></span>
<footer
class="border-border text-muted flex items-center justify-between gap-3 border-t px-3.5 py-3 text-xs max-sm:flex-col max-sm:items-start"
>
<span
>{{ t("user.orderTotal") }}
<strong class="text-primary"
><PriceText
:amount-minor="order.total_minor"
:currency="order.currency" /></strong
></span>
<div class="flex gap-2">
<VBtn v-if="order.status === 'pending_payment'" variant="primary" size="sm" type="button" :disabled="workingId === order.id" @click="pay(order)">{{ t("user.payNow") }}</VBtn>
<VBtn v-if="order.status === 'pending_payment'" size="sm" type="button" :disabled="workingId === order.id" @click="cancel(order)">{{ t("user.cancelOrder") }}</VBtn>
<NuxtLink class="inline-flex items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[13px] font-medium text-text no-underline" :to="`/user/orders/${order.id}`">{{ t("user.viewDetails") }}</NuxtLink>
<VBtn
v-if="order.status === 'pending_payment'"
variant="primary"
size="sm"
type="button"
:disabled="workingId === order.id"
@click="pay(order)"
>{{ t("user.payNow") }}</VBtn
>
<VBtn
v-if="order.status === 'pending_payment'"
size="sm"
type="button"
:disabled="workingId === order.id"
@click="cancel(order)"
>{{ t("user.cancelOrder") }}</VBtn
>
<NuxtLink
class="border-border bg-surface text-text inline-flex items-center rounded-md border px-2.5 py-1 text-[13px] font-medium no-underline"
:to="`/user/orders/${order.id}`"
>{{ t("user.viewDetails") }}</NuxtLink
>
</div>
</footer>
</VCard>
@@ -136,4 +201,3 @@ onMounted(() => {
</UiTabs>
</VCard>
</template>