152 lines
5.9 KiB
Vue
152 lines
5.9 KiB
Vue
<script setup lang="ts">
|
|
import { t as pick } from "@vmall/shared";
|
|
import type { ReviewableItem } from "@vmall/shared";
|
|
|
|
definePageMeta({ middleware: "auth" });
|
|
|
|
const { locale, t } = useI18n();
|
|
const { $api } = useNuxtApp();
|
|
const items = ref<ReviewableItem[]>([]);
|
|
const activeItemId = ref("");
|
|
const rating = ref(0);
|
|
const content = ref("");
|
|
const imageUrls = ref<string[]>([""]);
|
|
const loading = ref(true);
|
|
const working = ref(false);
|
|
const errorKey = ref("");
|
|
const successKey = ref("");
|
|
|
|
const activeItem = computed(
|
|
() => items.value.find((item) => item.order_item_id === activeItemId.value) ?? null,
|
|
);
|
|
|
|
async function load(): Promise<void> {
|
|
loading.value = true;
|
|
errorKey.value = "";
|
|
try {
|
|
items.value = await $api.listReviewableItems();
|
|
} catch {
|
|
errorKey.value = "user.reviewLoadFailed";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function begin(item: ReviewableItem): void {
|
|
activeItemId.value = item.order_item_id;
|
|
rating.value = 0;
|
|
content.value = "";
|
|
imageUrls.value = [""];
|
|
errorKey.value = "";
|
|
successKey.value = "";
|
|
}
|
|
|
|
function cancel(): void {
|
|
activeItemId.value = "";
|
|
rating.value = 0;
|
|
content.value = "";
|
|
imageUrls.value = [""];
|
|
errorKey.value = "";
|
|
}
|
|
|
|
function addImageUrl(): void {
|
|
imageUrls.value.push("");
|
|
}
|
|
|
|
function removeImageUrl(index: number): void {
|
|
if (imageUrls.value.length === 1) {
|
|
imageUrls.value[0] = "";
|
|
return;
|
|
}
|
|
imageUrls.value.splice(index, 1);
|
|
}
|
|
|
|
async function submit(): Promise<void> {
|
|
errorKey.value = "";
|
|
successKey.value = "";
|
|
const item = activeItem.value;
|
|
const text = content.value.trim();
|
|
if (!item || rating.value < 1 || !text) {
|
|
errorKey.value = "user.validationRequired";
|
|
return;
|
|
}
|
|
working.value = true;
|
|
try {
|
|
await $api.createReview({
|
|
order_item_id: item.order_item_id,
|
|
rating: rating.value,
|
|
content: { [locale.value]: text },
|
|
images: imageUrls.value.map((url) => url.trim()).filter(Boolean),
|
|
});
|
|
items.value = items.value.filter((entry) => entry.order_item_id !== item.order_item_id);
|
|
activeItemId.value = "";
|
|
rating.value = 0;
|
|
content.value = "";
|
|
imageUrls.value = [""];
|
|
successKey.value = "user.reviewSubmitSuccess";
|
|
} catch {
|
|
errorKey.value = "user.reviewSubmitFailed";
|
|
} finally {
|
|
working.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(() => void load());
|
|
</script>
|
|
|
|
<template>
|
|
<VCard class="min-h-[560px]">
|
|
<div class="mb-4 flex items-center justify-between gap-3">
|
|
<div>
|
|
<h1 class="text-text m-0 text-xl font-bold">{{ t("user.reviewsTitle") }}</h1>
|
|
<p class="text-muted mt-1 text-xs">{{ t("user.reviewCount", { n: items.length }) }}</p>
|
|
</div>
|
|
<NuxtLink class="text-primary text-sm no-underline" to="/user/orders">{{ t("user.myOrders") }}</NuxtLink>
|
|
</div>
|
|
<p v-if="successKey" class="text-success mb-4 text-sm">{{ t(successKey) }}</p>
|
|
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
|
|
<p v-else-if="errorKey" class="text-danger py-5 text-sm">{{ t(errorKey) }}</p>
|
|
<UiEmptyState v-else-if="items.length === 0" :text="t('user.noPendingReviews')" />
|
|
<div v-else class="space-y-3">
|
|
<VCard v-for="item in items" :key="item.order_item_id" :padded="false" class="border-border border p-4">
|
|
<div class="flex flex-wrap items-center gap-4">
|
|
<img class="border-border h-16 w-16 border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-text m-0 truncate text-sm font-medium">{{ pick(item.product_name, locale) }}</p>
|
|
<p class="text-muted mt-1 text-xs">{{ item.sku_code }}</p>
|
|
<dl class="text-muted mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
|
<div class="flex gap-1"><dt>{{ t("user.reviewOrder") }}:</dt><dd class="m-0">{{ item.order_no }}</dd></div>
|
|
<div class="flex gap-1"><dt>{{ t("user.reviewTime") }}:</dt><dd class="m-0">{{ item.created_at.slice(0, 10) }}</dd></div>
|
|
</dl>
|
|
</div>
|
|
<VBtn v-if="activeItemId !== item.order_item_id" variant="primary" type="button" @click="begin(item)">{{ t("user.reviewNow") }}</VBtn>
|
|
</div>
|
|
<form v-if="activeItemId === item.order_item_id" class="border-border mt-4 grid gap-3 border-t pt-4" @submit.prevent="submit">
|
|
<VField :label="t('user.reviewRating')">
|
|
<div class="flex items-center gap-1" role="radiogroup" :aria-label="t('user.reviewRating')">
|
|
<button v-for="star in [1, 2, 3, 4, 5]" :key="star" class="text-2xl leading-none" :class="star <= rating ? 'text-primary' : 'text-[#ddd]'" type="button" role="radio" :aria-checked="star === rating" :aria-label="String(star)" @click="rating = star">★</button>
|
|
</div>
|
|
</VField>
|
|
<VField :label="t('user.reviewContent')">
|
|
<textarea v-model="content" class="border-border bg-surface text-text min-h-24 rounded-md border p-2.5 text-sm" :placeholder="t('user.reviewContentHint')" required />
|
|
</VField>
|
|
<VField :label="t('user.reviewImages')">
|
|
<div class="grid gap-2">
|
|
<div v-for="(url, index) in imageUrls" :key="index" class="flex gap-2">
|
|
<VInput v-model="imageUrls[index]" :placeholder="t('user.reviewImagePlaceholder')" />
|
|
<VBtn type="button" @click="removeImageUrl(index)">{{ t("user.reviewRemoveImage") }}</VBtn>
|
|
</div>
|
|
<VBtn class="w-fit" type="button" @click="addImageUrl">{{ t("user.reviewAddImage") }}</VBtn>
|
|
</div>
|
|
</VField>
|
|
<p v-if="errorKey" class="text-danger m-0 text-xs">{{ t(errorKey) }}</p>
|
|
<div class="flex gap-2">
|
|
<VBtn variant="primary" type="submit" :disabled="working">{{ t("user.reviewSubmit") }}</VBtn>
|
|
<VBtn type="button" :disabled="working" @click="cancel">{{ t("user.reviewCancel") }}</VBtn>
|
|
</div>
|
|
</form>
|
|
</VCard>
|
|
</div>
|
|
</VCard>
|
|
</template>
|