Files
slipmatz-web/app/pages/orders.vue
Frank John Begornia bf701f8342 Replace Firebase Storage with MinIO and add user account features
- Storage Integration:
  * Remove Firebase Storage dependency and useFirebaseStorage composable
  * Implement direct MinIO uploads via POST /storage/upload with multipart/form-data
  * Upload canvas JSON, preview PNG, and production PNG as separate objects
  * Store public URLs and metadata in design records

- Authentication & Registration:
  * Add email/password registration page with validation
  * Integrate backend user session via /auth/login endpoint
  * Store backendUser.id as ownerId in design records
  * Auto-sync backend session on Firebase auth state changes

- User Account Pages:
  * Create profile page showing user details and backend session info
  * Create orders page with transaction history filtered by customerEmail
  * Add server proxy /api/orders to forward GET /transactions queries

- Navigation Improvements:
  * Replace inline auth buttons with avatar dropdown menu
  * Add Profile, Orders, and Logout options to dropdown
  * Implement outside-click and route-change handlers for dropdown
  * Display user initials in avatar badge

- API Updates:
  * Update transactions endpoint to accept amount as string
  * Format amount with .toFixed(2) in checkout success flow
  * Query orders by customerEmail instead of ownerId for consistency
2025-11-16 01:19:35 +08:00

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-slate-950 pb-16 text-slate-100">
<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-sky-400">Account</p>
<h1 class="text-3xl font-semibold text-white">Order history</h1>
<p class="text-sm text-slate-400">
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-800 bg-slate-900/50" />
<div class="h-28 animate-pulse rounded-2xl border border-slate-800 bg-slate-900/50" />
</div>
<div v-else-if="!isAuthenticated" class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
<h2 class="text-xl font-semibold text-white">Sign in to view orders</h2>
<p class="mt-2 text-sm text-slate-400">
Orders are associated with your Slipmatz account. Sign in to see the designs you've purchased.
</p>
<button
type="button"
class="mt-4 rounded-md bg-sky-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-500"
@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-400">
Signed in as <span class="font-medium text-slate-200">{{ customerEmail }}</span>
</div>
<button
type="button"
class="rounded-md border border-slate-700/70 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-300 transition hover:border-slate-500 hover:text-white"
@click="fetchOrders"
:disabled="ordersLoading"
>
{{ ordersLoading ? 'Refreshing' : 'Refresh' }}
</button>
</div>
<div v-if="ordersError" class="rounded-2xl border border-rose-500/60 bg-rose-500/10 p-6 text-sm text-rose-100">
{{ ordersError }}
</div>
<div v-if="ordersLoading" class="grid gap-4">
<div class="h-24 animate-pulse rounded-2xl border border-slate-800 bg-slate-900/50" />
<div class="h-24 animate-pulse rounded-2xl border border-slate-800 bg-slate-900/50" />
<div class="h-24 animate-pulse rounded-2xl border border-slate-800 bg-slate-900/50" />
</div>
<div v-else-if="orders.length === 0" class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6 text-sm text-slate-300">
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-800/60 bg-slate-900/80 p-6 shadow-lg shadow-slate-950/40"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-lg font-semibold text-white">
{{ formatAmount(order) }}
</h3>
<p class="text-sm text-slate-400">
{{ order.currency ? order.currency.toUpperCase() : 'USD' }} • {{ formatDate(order.createdAt || order.updatedAt) }}
</p>
</div>
<span v-if="order.status" class="rounded-full border border-slate-700/70 px-3 py-1 text-xs uppercase tracking-[0.25em] text-slate-300">
{{ order.status }}
</span>
</div>
<dl class="mt-4 grid gap-3 text-sm text-slate-300 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-200 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-200">{{ 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-200">{{ 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-200 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-sky-400 transition hover:text-sky-300"
>
Reopen design
<span aria-hidden="true"></span>
</NuxtLink>
</article>
</div>
</div>
</section>
</main>
</template>