- Add tailwindcss v4 + @tailwindcss/vite to mall, shop-admin, admin - Add @vmall/shared/theme.css tokens with html[data-accent] presets - Add @vmall/ui kit (VBtn/VBadge/VField/VInput/VCard/VPanel/VTable/VPage, VAccentSwatch, useAccent) as a Nuxt module - Convert all three apps to kit + utilities; delete ui.css/mall.css and every <style scoped>; consoles get accent presets, mall locked to red - Fix VCard boolean prop default (padding) and PDP/store stale useAsyncData keys on param navigation - Archive adopt-tailwind-design-system; new frontend-ui capability spec
259 lines
11 KiB
Vue
259 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { t as pick } from "@vmall/shared";
|
||
import type { Address, AddressBookEntry, CartItem, Coupon, GroupBuyIntent, LocalizedText } from "@vmall/shared";
|
||
|
||
type CheckoutGroup = {
|
||
shopId: string;
|
||
shopName: LocalizedText;
|
||
items: CartItem[];
|
||
};
|
||
|
||
definePageMeta({ middleware: "auth" });
|
||
|
||
const { $api } = useNuxtApp();
|
||
const { locale, t } = useI18n();
|
||
const { currency } = usePrefs();
|
||
const cartStore = useCartStore();
|
||
const router = useRouter();
|
||
const pendingOrderIds = useState<string[]>("checkout-orders", () => []);
|
||
/** Group-buying intent chosen on the group-buying page, if any. */
|
||
const groupIntent = useState<GroupBuyIntent | null>("checkout-group-intent", () => null);
|
||
|
||
const items = ref<CartItem[]>([]);
|
||
const addresses = ref<AddressBookEntry[]>([]);
|
||
/** Owned, still-claimable coupons, by shop. */
|
||
const coupons = ref<Coupon[]>([]);
|
||
const selectedCoupon = reactive<Record<string, string>>({});
|
||
const selectedAddressId = ref("");
|
||
const remark = ref("");
|
||
const loading = ref(true);
|
||
const submitting = ref(false);
|
||
const error = ref("");
|
||
|
||
// Inline manual form, used only when the customer has no saved address.
|
||
const manual = reactive<Address>({
|
||
recipient: "",
|
||
phone: "",
|
||
country: "US",
|
||
region: "",
|
||
city: "",
|
||
line1: "",
|
||
postal_code: "",
|
||
});
|
||
|
||
const stepLabels = computed(() => [
|
||
t("checkout.steps.cart"),
|
||
t("checkout.steps.order"),
|
||
t("checkout.steps.payment"),
|
||
t("checkout.steps.complete"),
|
||
]);
|
||
|
||
// Grouped by the cart line's own shop, which the cart API returns.
|
||
const groups = computed<CheckoutGroup[]>(() => {
|
||
const grouped = new Map<string, CheckoutGroup>();
|
||
for (const item of items.value) {
|
||
const group = grouped.get(item.shop_id) ?? {
|
||
shopId: item.shop_id,
|
||
shopName: item.shop_name,
|
||
items: [],
|
||
};
|
||
group.items.push(item);
|
||
grouped.set(item.shop_id, group);
|
||
}
|
||
return [...grouped.values()];
|
||
});
|
||
|
||
const sourceCurrency = computed(() => items.value[0]?.currency ?? currency.value);
|
||
const totalMinor = computed(() =>
|
||
items.value.reduce((total, item) => total + item.unit_price_minor * item.qty, 0),
|
||
);
|
||
|
||
function imageFor(item: CartItem): string {
|
||
return item.image ?? "/mock/product-1.svg";
|
||
}
|
||
|
||
/** One coupon per shop order; the server decides eligibility and the amount. */
|
||
function couponsForShop(shopId: string): Coupon[] {
|
||
return coupons.value.filter((c) => c.shop_id === shopId && c.status === "claimed");
|
||
}
|
||
|
||
function selectedCouponFor(shopId: string): Coupon | null {
|
||
const id = selectedCoupon[shopId];
|
||
if (!id) return null;
|
||
return coupons.value.find((c) => c.id === id) ?? null;
|
||
}
|
||
|
||
function selectedAddress(): Address | null {
|
||
const saved = addresses.value.find((a) => a.id === selectedAddressId.value);
|
||
if (saved) {
|
||
return {
|
||
recipient: saved.recipient,
|
||
phone: saved.phone,
|
||
country: saved.country,
|
||
region: saved.region,
|
||
city: saved.city,
|
||
line1: saved.line1,
|
||
postal_code: saved.postal_code,
|
||
};
|
||
}
|
||
if (addresses.value.length > 0) return null;
|
||
// Manual fallback: every required field must be filled.
|
||
if (!manual.recipient || !manual.phone || !manual.country || !manual.city || !manual.line1) {
|
||
return null;
|
||
}
|
||
return { ...manual };
|
||
}
|
||
|
||
async function loadCart(): Promise<void> {
|
||
loading.value = true;
|
||
error.value = "";
|
||
try {
|
||
const [snapshot, list, couponList] = await Promise.all([
|
||
$api.getCart(),
|
||
$api.listMyAddresses(),
|
||
// Coupons are optional: a failure must not block checkout.
|
||
$api.listMyCoupons().catch(() => [] as Coupon[]),
|
||
]);
|
||
items.value = snapshot.items;
|
||
addresses.value = list;
|
||
coupons.value = couponList;
|
||
// A stashed group intent only applies while its SKU is still in the cart.
|
||
const intent = groupIntent.value;
|
||
if (intent && !items.value.some((item) => item.sku_id === intent.sku_id)) {
|
||
groupIntent.value = null;
|
||
}
|
||
selectedAddressId.value =
|
||
list.find((address) => address.is_default)?.id ?? list[0]?.id ?? "";
|
||
if (items.value.length === 0) {
|
||
await router.replace("/cart");
|
||
}
|
||
} catch {
|
||
error.value = t("checkout.loadError");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function submitOrder(): Promise<void> {
|
||
const address = selectedAddress();
|
||
if (!address || items.value.length === 0) {
|
||
error.value = t("checkout.addressRequired");
|
||
return;
|
||
}
|
||
submitting.value = true;
|
||
error.value = "";
|
||
try {
|
||
const couponByShop: Record<string, string> = {};
|
||
for (const [shopId, couponId] of Object.entries(selectedCoupon)) {
|
||
if (couponId) couponByShop[shopId] = couponId;
|
||
}
|
||
const orders = await $api.checkout(
|
||
address,
|
||
currency.value,
|
||
couponByShop,
|
||
groupIntent.value ?? undefined,
|
||
);
|
||
groupIntent.value = null;
|
||
pendingOrderIds.value = orders.map((order) => order.id);
|
||
const snapshot = await $api.getCart();
|
||
items.value = snapshot.items;
|
||
await cartStore.refresh();
|
||
await router.push("/checkout/pay");
|
||
} catch {
|
||
error.value = t("checkout.submitError");
|
||
} finally {
|
||
submitting.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => void loadCart());
|
||
|
||
// Leaving without submitting must not reprice a later order: the intent is
|
||
// consumed by a successful submit, which clears it before navigating to pay.
|
||
onBeforeUnmount(() => {
|
||
groupIntent.value = null;
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="min-h-screen bg-bg font-sans text-text">
|
||
<div class="mx-auto max-w-mall px-4 py-6">
|
||
<UiStepBar :steps="stepLabels" :active="1" />
|
||
<header class="mb-4"><h1 class="m-0 text-xl font-medium">{{ t("checkout.title") }}</h1></header>
|
||
|
||
<p v-if="error" class="mb-4 border border-danger/30 bg-danger/10 px-3.5 py-2.5 text-danger">{{ error }}</p>
|
||
<template v-if="!loading && items.length > 0">
|
||
<VCard class="mb-4 p-5">
|
||
<h2 class="mb-4 mt-0 text-base font-semibold">{{ t("checkout.address") }}</h2>
|
||
<div v-if="addresses.length > 0" class="grid gap-2">
|
||
<label
|
||
v-for="address in addresses"
|
||
:key="address.id"
|
||
class="flex cursor-pointer items-start gap-3 rounded-md border border-border p-3 text-sm has-[:checked]:border-primary has-[:checked]:bg-primary-soft"
|
||
>
|
||
<input v-model="selectedAddressId" class="mt-1 h-4 w-4 accent-primary" type="radio" name="shipping-address" :value="address.id" />
|
||
<span class="flex min-w-0 flex-1 flex-col gap-1">
|
||
<span class="flex flex-wrap gap-2"><strong>{{ address.recipient }}</strong><span>{{ address.phone }}</span></span>
|
||
<span class="text-muted">{{ address.region }} {{ address.city }} {{ address.line1 }} {{ address.postal_code }}</span>
|
||
</span>
|
||
<VBadge v-if="address.is_default" tone="blue">{{ t("checkout.defaultAddress") }}</VBadge>
|
||
</label>
|
||
</div>
|
||
<div v-else>
|
||
<p class="mb-3 text-sm text-muted">{{ t("checkout.noSavedAddress") }}</p>
|
||
<div class="grid gap-3 sm:grid-cols-2">
|
||
<VField :label="t('user.recipient')"><VInput v-model.trim="manual.recipient" type="text" :placeholder="t('user.recipient')" /></VField>
|
||
<VField :label="t('user.phone')"><VInput v-model.trim="manual.phone" type="text" :placeholder="t('user.phone')" /></VField>
|
||
<VField :label="t('user.country')"><VInput v-model.trim="manual.country" type="text" :placeholder="t('user.country')" /></VField>
|
||
<VField :label="t('user.region')"><VInput v-model.trim="manual.region" type="text" :placeholder="t('user.region')" /></VField>
|
||
<VField :label="t('user.city')"><VInput v-model.trim="manual.city" type="text" :placeholder="t('user.city')" /></VField>
|
||
<VField :label="t('user.postalCode')"><VInput v-model.trim="manual.postal_code" type="text" :placeholder="t('user.postalCode')" /></VField>
|
||
<VField class="sm:col-span-2" :label="t('user.line1')"><VInput v-model.trim="manual.line1" type="text" :placeholder="t('user.line1')" /></VField>
|
||
</div>
|
||
</div>
|
||
</VCard>
|
||
|
||
<VCard class="mb-4 p-5">
|
||
<h2 class="mb-4 mt-0 text-base font-semibold">{{ t("checkout.orderPreview") }}</h2>
|
||
<div v-for="group in groups" :key="group.shopId" class="border-b border-border pb-3 last:border-b-0 last:pb-0">
|
||
<header class="border-b border-border py-2 text-sm font-medium">{{ pick(group.shopName, locale) || t("checkout.shop") }}</header>
|
||
<div v-for="item in group.items" :key="item.sku_id" class="grid grid-cols-[56px_minmax(0,1fr)_auto_auto_auto] items-center gap-3 border-b border-bg py-3 last:border-b-0">
|
||
<img class="h-14 w-14 object-cover" :src="imageFor(item)" :alt="pick(item.product_name, locale)" />
|
||
<div class="flex min-w-0 flex-col gap-1"><strong class="truncate">{{ pick(item.product_name, locale) }}</strong><span class="text-xs text-muted">{{ item.sku_code }}</span></div>
|
||
<PriceText :amount-minor="item.unit_price_minor" :currency="item.currency" />
|
||
<span class="text-xs text-muted">× {{ item.qty }}</span>
|
||
<strong class="text-right text-primary"><PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="item.currency" /></strong>
|
||
</div>
|
||
<div v-if="!groupIntent && couponsForShop(group.shopId).length > 0" class="flex flex-wrap items-center gap-3 pt-3 text-sm">
|
||
<span class="text-muted">{{ t("checkout.coupon") }}</span>
|
||
<select v-model="selectedCoupon[group.shopId]" class="rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:border-primary focus:outline-2 focus:outline-primary/30">
|
||
<option value="">{{ t("checkout.noCoupon") }}</option>
|
||
<option v-for="coupon in couponsForShop(group.shopId)" :key="coupon.id" :value="coupon.id">{{ pick(coupon.title, locale) }}</option>
|
||
</select>
|
||
<span v-if="selectedCouponFor(group.shopId)" class="text-danger">− <PriceText :amount-minor="selectedCouponFor(group.shopId)?.amount_minor ?? 0" :currency="selectedCouponFor(group.shopId)?.currency ?? sourceCurrency" /></span>
|
||
</div>
|
||
</div>
|
||
</VCard>
|
||
|
||
<VCard class="mb-4 p-5">
|
||
<label class="mb-1 block text-sm font-medium" for="order-remark">{{ t("checkout.remark") }}</label>
|
||
<textarea id="order-remark" v-model="remark" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:border-primary focus:outline-2 focus:outline-primary/30" :placeholder="t('checkout.remarkPlaceholder')" rows="3" />
|
||
</VCard>
|
||
|
||
<footer class="mb-4 flex flex-wrap items-center justify-end gap-4 rounded-lg border border-border bg-surface p-4 shadow-sm">
|
||
<span class="text-sm">{{ t("checkout.total") }}:</span>
|
||
<strong class="text-lg text-primary"><PriceText :amount-minor="totalMinor" :currency="sourceCurrency" /></strong>
|
||
<VBtn variant="primary" type="button" :disabled="submitting" @click="submitOrder">{{ t("checkout.submit") }}</VBtn>
|
||
</footer>
|
||
</template>
|
||
<VCard v-else-if="!loading" class="flex flex-col items-center gap-4 p-8 text-center">
|
||
<UiEmptyState :text="t('checkout.noItems')" />
|
||
<NuxtLink class="inline-flex items-center justify-center rounded-md border border-border bg-surface px-4 py-2 text-sm font-medium hover:bg-bg" to="/cart">{{ t("checkout.backToCart") }}</NuxtLink>
|
||
</VCard>
|
||
<div v-else class="py-8 text-center text-muted">{{ $t("common.loading") }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
|