147 lines
5.1 KiB
Vue
147 lines
5.1 KiB
Vue
<script setup lang="ts">
|
||
import type { Aftersale, AftersaleKind, Order } from "@vmall/shared";
|
||
|
||
definePageMeta({ middleware: "auth" });
|
||
|
||
const { t } = useI18n();
|
||
const { $api } = useNuxtApp();
|
||
const route = useRoute();
|
||
const { ensureCurrencies, exponentFor } = usePrice();
|
||
const order = ref<Order | null>(null);
|
||
const aftersales = ref<Aftersale[]>([]);
|
||
const loading = ref(true);
|
||
const working = ref(false);
|
||
const errorKey = ref("");
|
||
const kind = ref<AftersaleKind>("refund_only");
|
||
const reason = ref("");
|
||
const amountMajor = ref("");
|
||
const evidence = ref("");
|
||
|
||
const orderId = computed(() => String(route.query.order_id ?? ""));
|
||
const itemId = computed(() => String(route.query.item_id ?? ""));
|
||
const item = computed(() => order.value?.items.find((entry) => entry.id === itemId.value) ?? null);
|
||
const remainingMinor = computed(() => {
|
||
const line = item.value;
|
||
if (!line || !order.value) return 0;
|
||
const refunded = aftersales.value
|
||
.filter((row) => row.order_item_id === line.id && row.status === "refunded")
|
||
.reduce((sum, row) => sum + row.amount_minor, 0);
|
||
return Math.max(0, line.unit_price_minor * line.qty - refunded);
|
||
});
|
||
|
||
async function load(): Promise<void> {
|
||
loading.value = true;
|
||
try {
|
||
await ensureCurrencies();
|
||
const [loadedOrder, loadedAftersales] = await Promise.all([
|
||
$api.getOrder(orderId.value),
|
||
$api.listMyAftersales(),
|
||
]);
|
||
order.value = loadedOrder;
|
||
aftersales.value = loadedAftersales;
|
||
} catch {
|
||
errorKey.value = "user.aftersaleLoadFailed";
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function amountMinor(): number | null {
|
||
const raw = Number(amountMajor.value);
|
||
if (!Number.isFinite(raw) || raw <= 0 || !order.value) return null;
|
||
const exponent = exponentFor(order.value.currency);
|
||
const minor = Math.round(raw * 10 ** exponent);
|
||
return Number.isSafeInteger(minor) && minor > 0 ? minor : null;
|
||
}
|
||
|
||
async function submit(): Promise<void> {
|
||
errorKey.value = "";
|
||
const content = reason.value.trim();
|
||
const amount = amountMinor();
|
||
if (!item.value || !order.value || !content || amount === null) {
|
||
errorKey.value = "user.validationRequired";
|
||
return;
|
||
}
|
||
if (amount > remainingMinor.value) {
|
||
errorKey.value = "user.aftersaleAmountHint";
|
||
return;
|
||
}
|
||
working.value = true;
|
||
try {
|
||
const created = await $api.applyForAftersale({
|
||
order_item_id: item.value.id,
|
||
kind: kind.value,
|
||
reason: { en: content, zh: content },
|
||
amount_minor: amount,
|
||
evidence: evidence.value
|
||
.split("\n")
|
||
.map((url) => url.trim())
|
||
.filter(Boolean),
|
||
});
|
||
await navigateTo(`/user/aftersales/${created.id}`);
|
||
} catch {
|
||
errorKey.value = "user.aftersaleSubmitFailed";
|
||
} finally {
|
||
working.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => void load());
|
||
</script>
|
||
|
||
<template>
|
||
<VCard class="min-h-[560px]">
|
||
<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="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="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="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="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="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>
|