305 lines
11 KiB
Vue
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>
|