228 lines
7.8 KiB
Vue
228 lines
7.8 KiB
Vue
<script setup lang="ts">
|
|
interface OrderRecord {
|
|
id?: string;
|
|
designId?: string;
|
|
templateId?: string | null;
|
|
amount?: string | number;
|
|
currency?: string;
|
|
customerEmail?: string | null;
|
|
status?: string | null;
|
|
createdAt?: string | null;
|
|
updatedAt?: string | null;
|
|
stripeSessionId?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
const router = useRouter();
|
|
const loginModal = useLoginModal();
|
|
const { user, backendUser, initAuth, isLoading } = useAuth();
|
|
|
|
const customerEmail = computed(() => backendUser.value?.email || user.value?.email || null);
|
|
|
|
const orders = ref<OrderRecord[]>([]);
|
|
const ordersLoading = ref(false);
|
|
const ordersError = ref<string | null>(null);
|
|
|
|
const isAuthenticated = computed(() => Boolean(user.value && customerEmail.value));
|
|
|
|
onMounted(() => {
|
|
initAuth();
|
|
});
|
|
|
|
const fetchOrders = async () => {
|
|
if (!process.client) {
|
|
return;
|
|
}
|
|
|
|
if (!customerEmail.value) {
|
|
return;
|
|
}
|
|
|
|
ordersLoading.value = true;
|
|
ordersError.value = null;
|
|
|
|
try {
|
|
const response = await $fetch<OrderRecord[]>("/api/orders", {
|
|
query: { customerEmail: customerEmail.value ?? undefined },
|
|
});
|
|
|
|
if (Array.isArray(response)) {
|
|
orders.value = response;
|
|
} else if (response && typeof response === "object" && "orders" in response) {
|
|
orders.value = (response as any).orders ?? [];
|
|
} else {
|
|
orders.value = [];
|
|
}
|
|
} catch (error: any) {
|
|
console.error("Failed to load order history", error);
|
|
ordersError.value = error?.message ?? "Unable to load orders";
|
|
orders.value = [];
|
|
} finally {
|
|
ordersLoading.value = false;
|
|
}
|
|
};
|
|
|
|
watch(
|
|
() => customerEmail.value,
|
|
(email) => {
|
|
if (!process.client) {
|
|
return;
|
|
}
|
|
if (email) {
|
|
fetchOrders();
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
const formatAmount = (record: OrderRecord) => {
|
|
const rawAmount = record.amount;
|
|
const amountString =
|
|
typeof rawAmount === "string"
|
|
? rawAmount
|
|
: typeof rawAmount === "number"
|
|
? rawAmount.toString()
|
|
: "0";
|
|
|
|
const numericAmount = Number.parseFloat(amountString);
|
|
const currencyCode = (record.currency ?? "USD").toUpperCase();
|
|
|
|
if (Number.isFinite(numericAmount)) {
|
|
return new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: currencyCode,
|
|
}).format(numericAmount);
|
|
}
|
|
|
|
return `${amountString} ${currencyCode}`.trim();
|
|
};
|
|
|
|
const formatDate = (value?: string | null) => {
|
|
if (!value) {
|
|
return "—";
|
|
}
|
|
const parsed = new Date(value);
|
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
|
};
|
|
|
|
const openLogin = () => {
|
|
loginModal.value = true;
|
|
router.push("/");
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<main class="min-h-screen bg-white pb-16 text-slate-900">
|
|
<AppNavbar />
|
|
|
|
<section class="mx-auto flex max-w-4xl flex-col gap-8 px-4 pt-16">
|
|
<header class="space-y-3">
|
|
<p class="text-sm uppercase tracking-[0.35em] text-slate-600">Account</p>
|
|
<h1 class="text-3xl font-semibold text-slate-900">Order history</h1>
|
|
<p class="text-sm text-slate-600">
|
|
Review your completed purchases and keep track of the designs linked to each order.
|
|
</p>
|
|
</header>
|
|
|
|
<div v-if="isLoading" class="grid gap-4">
|
|
<div class="h-28 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
|
<div class="h-28 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
|
</div>
|
|
|
|
<div v-else-if="!isAuthenticated" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
|
<h2 class="text-xl font-semibold text-slate-900">Sign in to view orders</h2>
|
|
<p class="mt-2 text-sm text-slate-600">
|
|
Orders are associated with your TableJerseys account. Sign in to see the designs you've purchased.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
class="mt-4 rounded-md border-2 border-slate-900 bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white hover:text-slate-900"
|
|
@click="openLogin"
|
|
>
|
|
Sign in
|
|
</button>
|
|
</div>
|
|
|
|
<div v-else class="space-y-6">
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div class="text-sm text-slate-600">
|
|
Signed in as <span class="font-medium text-slate-900">{{ customerEmail }}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="rounded-md border border-slate-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-700 transition hover:border-slate-900 hover:text-slate-900"
|
|
@click="fetchOrders"
|
|
:disabled="ordersLoading"
|
|
>
|
|
{{ ordersLoading ? 'Refreshing…' : 'Refresh' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="ordersError" class="rounded-2xl border border-rose-200 bg-rose-50 p-6 text-sm text-rose-700">
|
|
{{ ordersError }}
|
|
</div>
|
|
|
|
<div v-if="ordersLoading" class="grid gap-4">
|
|
<div class="h-24 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
|
<div class="h-24 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
|
<div class="h-24 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
|
</div>
|
|
|
|
<div v-else-if="orders.length === 0" class="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-700 shadow-sm">
|
|
No orders yet. When you complete a purchase, your history will show up here for easy reference.
|
|
</div>
|
|
|
|
<div v-else class="space-y-4">
|
|
<article
|
|
v-for="order in orders"
|
|
:key="order.id || `${order.designId}-${order.createdAt}`"
|
|
class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
|
|
>
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-slate-900">
|
|
{{ formatAmount(order) }}
|
|
</h3>
|
|
<p class="text-sm text-slate-600">
|
|
{{ order.currency ? order.currency.toUpperCase() : 'USD' }} • {{ formatDate(order.createdAt || order.updatedAt) }}
|
|
</p>
|
|
</div>
|
|
<span v-if="order.status" class="rounded-full border border-slate-300 bg-slate-50 px-3 py-1 text-xs uppercase tracking-[0.25em] text-slate-700">
|
|
{{ order.status }}
|
|
</span>
|
|
</div>
|
|
|
|
<dl class="mt-4 grid gap-3 text-sm text-slate-700 sm:grid-cols-2">
|
|
<div v-if="order.designId">
|
|
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">Design ID</dt>
|
|
<dd class="text-slate-900 break-all">{{ order.designId }}</dd>
|
|
</div>
|
|
<div v-if="order.templateId">
|
|
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">Template</dt>
|
|
<dd class="text-slate-900">{{ order.templateId }}</dd>
|
|
</div>
|
|
<div v-if="order.customerEmail">
|
|
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">Receipt sent to</dt>
|
|
<dd class="text-slate-900">{{ order.customerEmail }}</dd>
|
|
</div>
|
|
<div v-if="order.stripeSessionId">
|
|
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">Stripe session</dt>
|
|
<dd class="text-slate-900 break-all">{{ order.stripeSessionId }}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<NuxtLink
|
|
v-if="order.designId"
|
|
:to="{ path: '/', query: { designId: order.designId } }"
|
|
class="mt-4 inline-flex items-center gap-2 text-sm font-semibold text-slate-900 transition hover:text-slate-700"
|
|
>
|
|
Reopen design
|
|
<span aria-hidden="true">→</span>
|
|
</NuxtLink>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</template>
|