Files
vmall/apps/mall/pages/stores/[id].vue
T

305 lines
11 KiB
Vue

<script setup lang="ts">
import { ApiError } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
import type { Paged, Product } from "@vmall/shared";
import { lowestSku } from "~/utils/product";
import { useSessionStore } from "~/stores/session";
const route = useRoute();
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const session = useSessionStore();
const signInThenReturn = useSignInRedirect();
const slug = computed(() => {
const raw = route.params.id;
return typeof raw === "string" ? raw : (raw?.[0] ?? "");
});
// A rejected read (unknown or suspended slug) leaves this null, which renders
// the "unknown store" branch rather than an invented profile.
const { data: shop } = await useAsyncData(
// Keyed by slug: Nuxt remounts this page on param change, and a static key
// would serve the previous store's cached entry without re-running the handler.
`shop-profile-${slug.value}`,
() => $api.getShop(slug.value),
{
watch: [slug],
default: () => null,
},
);
const store = computed(() => shop.value);
const favorite = ref(false);
const favoriteLoading = ref(false);
const favoriteBusy = ref(false);
const favoriteError = ref("");
let favoriteRequestVersion = 0;
// Only sorts the catalog model can answer; sales and comments have no model.
const sortMode = ref<"default" | "price">("default");
const sortOrder = ref<"asc" | "desc">("desc");
const page = ref(1);
const perPage = 12;
const emptyPage: Paged<Product> = { items: [], total: 0, page: 1, per_page: perPage };
const { data: productResult } = await useAsyncData(
`shop-products-${slug.value}`,
() => {
const current = store.value;
if (!current) return Promise.resolve(emptyPage);
return $api.listProducts({
shop_id: current.id,
sort: sortMode.value === "price" ? "price" : undefined,
order: sortMode.value === "price" ? sortOrder.value : undefined,
page: page.value,
per_page: perPage,
});
},
{ watch: [store, sortMode, sortOrder, page], default: () => emptyPage },
);
const breadcrumb = computed(() => [
{ label: t("stores.breadcrumbHome"), to: "/" },
{ label: t("stores.directory"), to: "/stores" },
{ label: store.value ? pick(store.value.name, locale.value) : t("stores.unknownStore") },
]);
/** Only the scores the platform actually set; nothing is defaulted. */
const rateRows = computed(() => {
const current = store.value;
if (!current) return [];
const rows = [
{ label: t("stores.score"), value: current.score_rating },
{ label: t("stores.agreement"), value: current.score_agreement },
{ label: t("stores.service"), value: current.score_service },
{ label: t("stores.speed"), value: current.score_speed },
];
return rows.filter(
(row): row is { label: string; value: number } => typeof row.value === "number",
);
});
function priceMinorOf(product: Product): number {
return lowestSku(product)?.price_minor ?? 0;
}
function currencyOf(product: Product): string {
return lowestSku(product)?.currency ?? "USD";
}
function chooseSort(mode: "default" | "price"): void {
if (sortMode.value === mode && mode !== "default") {
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
} else {
sortMode.value = mode;
sortOrder.value = mode === "price" ? "asc" : "desc";
}
page.value = 1;
}
watch(
() => [store.value?.id, session.isLoggedIn] as const,
async ([id, loggedIn]) => {
const version = ++favoriteRequestVersion;
favorite.value = false;
favoriteLoading.value = Boolean(id && loggedIn);
favoriteBusy.value = false;
favoriteError.value = "";
if (!id || !loggedIn) return;
try {
const result = await $api.listFavorites({ kind: "shop", target_id: id, per_page: 1 });
if (version === favoriteRequestVersion && store.value?.id === id) {
favorite.value = result.items.length > 0;
}
} catch {
if (version === favoriteRequestVersion && store.value?.id === id) {
favoriteError.value = t("stores.favoriteLoadFailed");
}
} finally {
if (version === favoriteRequestVersion) favoriteLoading.value = false;
}
},
{ immediate: true },
);
const toggleFavorite = async (): Promise<void> => {
const current = store.value;
if (!current || favoriteLoading.value || favoriteBusy.value) return;
if (!session.isLoggedIn) {
await signInThenReturn();
return;
}
const version = ++favoriteRequestVersion;
favoriteBusy.value = true;
favoriteError.value = "";
try {
if (favorite.value) {
await $api.removeShopFavorite(current.id);
if (version === favoriteRequestVersion && store.value?.id === current.id) {
favorite.value = false;
}
} else {
await $api.addShopFavorite(current.id);
if (version === favoriteRequestVersion && store.value?.id === current.id) {
favorite.value = true;
}
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
if (version === favoriteRequestVersion && store.value?.id === current.id) {
await signInThenReturn();
}
return;
}
if (version === favoriteRequestVersion && store.value?.id === current.id) {
favoriteError.value = t("stores.favoriteFailed");
}
} finally {
if (version === favoriteRequestVersion) favoriteBusy.value = false;
}
};
</script>
<template>
<div class="bg-bg text-text min-h-screen pb-16 font-sans">
<UiBreadcrumb :items="breadcrumb" />
<template v-if="store">
<div
v-if="store.banner"
class="max-w-mall bg-bg mx-auto h-[350px] w-full overflow-hidden px-4 max-sm:h-[220px]"
>
<img
class="h-full w-full object-cover"
:src="store.banner"
:alt="pick(store.name, locale)"
/>
</div>
<div
class="max-w-mall mx-auto mt-5 grid w-full gap-5 px-4 lg:grid-cols-[270px_minmax(0,1fr)] lg:items-start"
>
<aside class="min-w-0 space-y-5">
<VCard>
<div class="border-border flex items-center gap-3 border-b pb-3">
<img
v-if="store.logo"
class="border-border h-[58px] w-[58px] border object-contain"
:src="store.logo"
:alt="pick(store.name, locale)"
/><span
v-else
class="border-border bg-bg text-muted flex h-[58px] w-[58px] items-center justify-center border text-lg"
>{{ pick(store.name, locale).slice(0, 1) }}</span
>
<div>
<h1 class="m-0 text-[15px] leading-[1.45]">{{ pick(store.name, locale) }}</h1>
<p class="text-primary mt-1 text-xs">{{ t("stores.positiveRate") }}</p>
</div>
</div>
<div v-if="rateRows.length" class="border-border space-y-1 border-b py-3">
<div
v-for="row in rateRows"
:key="row.label"
class="text-muted flex min-h-[25px] items-center gap-1 text-xs"
>
<span class="w-16">{{ row.label }}</span
><UiRatingStars :value="row.value" :size="13" /><strong
class="text-primary ml-auto font-medium"
>{{ row.value.toFixed(1) }}</strong
>
</div>
</div>
<dl class="text-muted my-3 text-xs">
<div v-if="store.company" class="my-2 grid grid-cols-[42px_1fr] gap-2">
<dt class="text-muted">{{ t("stores.company") }}</dt>
<dd class="m-0 min-w-0 break-words">{{ store.company }}</dd>
</div>
<div v-if="store.region" class="my-2 grid grid-cols-[42px_1fr] gap-2">
<dt class="text-muted">{{ t("stores.region") }}</dt>
<dd class="m-0 min-w-0 break-words">{{ store.region }}</dd>
</div>
<div v-if="store.address" class="my-2 grid grid-cols-[42px_1fr] gap-2">
<dt class="text-muted">{{ t("stores.address") }}</dt>
<dd class="m-0 min-w-0 break-words">{{ pick(store.address, locale) }}</dd>
</div>
</dl>
<VBtn
class="w-full"
:class="favorite ? 'border-primary text-primary' : ''"
type="button"
:disabled="favoriteLoading || favoriteBusy"
@click="toggleFavorite"
>{{ favorite ? t("stores.favorited") : t("stores.favorite") }}</VBtn
>
<p v-if="favoriteError" class="text-danger mt-1.5 text-xs">{{ favoriteError }}</p>
</VCard>
<VCard v-if="store.notice || store.after_sale">
<template v-if="store.notice"
><h2 class="border-primary mb-3 border-l-[3px] pl-2 text-sm font-medium">
{{ t("stores.notice") }}
</h2>
<p class="text-text text-sm">{{ pick(store.notice, locale) }}</p></template
>
<template v-if="store.after_sale"
><h2 class="border-primary mb-3 border-l-[3px] pl-2 text-sm font-medium">
{{ t("stores.afterSale") }}
</h2>
<p class="text-text text-sm">{{ pick(store.after_sale, locale) }}</p></template
>
</VCard>
</aside>
<section class="min-w-0">
<header
class="border-border bg-bg flex items-center justify-between border px-4 py-3 max-sm:flex-col max-sm:items-start max-sm:gap-2.5"
>
<h2 class="border-primary m-0 border-l-[3px] pl-2 text-[15px] font-medium">
{{ t("stores.storeProducts") }}
</h2>
<div class="flex">
<button
type="button"
class="border-border text-muted hover:text-primary border-0 border-r bg-transparent px-3 py-0 text-xs"
:class="sortMode === 'default' ? 'text-primary font-bold' : ''"
@click="chooseSort('default')"
>
{{ t("stores.sortDefault") }}</button
><button
type="button"
class="text-muted hover:text-primary border-0 bg-transparent px-3 py-0 text-xs"
:class="sortMode === 'price' ? 'text-primary font-bold' : ''"
@click="chooseSort('price')"
>
{{ t("stores.sortPrice") }}
</button>
</div>
</header>
<div
v-if="productResult.items.length"
class="mt-4 grid [grid-template-columns:repeat(auto-fill,minmax(220px,1fr))] gap-4"
>
<UiProductCard
v-for="product in productResult.items"
:key="product.id"
:product="product"
/>
</div>
<UiEmptyState v-else :text="t('stores.noProducts')" />
<UiPagination
:page="productResult.page"
:total="productResult.total"
:per-page="productResult.per_page"
@change="page = $event"
/>
</section>
</div>
</template>
<div v-else class="max-w-mall mx-auto min-h-[360px] w-full px-4 pt-[60px] text-center">
<UiEmptyState :text="t('stores.unknownStore')" /><NuxtLink
to="/stores"
class="border-border bg-surface text-text mt-4 inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium no-underline"
>{{ t("stores.backToStores") }}</NuxtLink
>
</div>
</div>
</template>