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
This commit is contained in:
Frank John Begornia
2025-11-16 01:19:35 +08:00
parent 0ff41822af
commit bf701f8342
19 changed files with 1807 additions and 223 deletions

View File

@@ -0,0 +1,235 @@
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const sessionId = computed(() => {
const raw = route.query.session_id
if (Array.isArray(raw)) {
return raw[0]
}
return typeof raw === 'string' ? raw : null
})
const { data: session, pending, error } = useAsyncData('checkout-session', async () => {
if (!sessionId.value) {
return null
}
return await $fetch(`/api/checkout/${sessionId.value}`)
}, {
watch: [sessionId],
})
const amountLabel = computed(() => {
if (!session.value?.amountTotal) {
return null
}
const currencyCode = session.value.currency?.toUpperCase() ?? 'USD'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(session.value.amountTotal / 100)
})
const copyStatus = ref<'idle' | 'copied' | 'error'>('idle')
const supportsClipboard = ref(false)
const transactionStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
const transactionMessage = ref<string | null>(null)
onMounted(() => {
supportsClipboard.value = typeof navigator !== 'undefined' && !!navigator.clipboard
})
watch(
() => session.value,
async (sessionValue) => {
if (!process.client) {
return
}
if (transactionStatus.value !== 'idle') {
return
}
if (!sessionId.value || !sessionValue?.id) {
return
}
if (sessionValue.paymentStatus !== 'paid') {
return
}
transactionStatus.value = 'saving'
try {
await $fetch('/api/transactions', {
method: 'POST',
body: {
stripeSessionId: sessionValue.id,
designId: sessionValue.metadata?.designId ?? `unknown-${sessionValue.id}`,
templateId: sessionValue.metadata?.templateId ?? null,
amount: sessionValue.amountTotal
? (sessionValue.amountTotal / 100).toFixed(2)
: "0",
currency: sessionValue.currency ?? 'usd',
customerEmail: sessionValue.customerEmail ?? null,
customerDetails: sessionValue.customerDetails,
},
})
transactionStatus.value = 'saved'
} catch (err: any) {
console.error('Failed to persist transaction', err)
transactionStatus.value = 'error'
transactionMessage.value = err?.message ?? 'Unable to record this transaction.'
}
},
{ immediate: true }
)
const copySessionId = async () => {
if (!supportsClipboard.value || !sessionId.value) {
return
}
try {
await navigator.clipboard.writeText(sessionId.value)
copyStatus.value = 'copied'
setTimeout(() => {
copyStatus.value = 'idle'
}, 2000)
} catch (err) {
console.error('Failed to copy session id', err)
copyStatus.value = 'error'
setTimeout(() => {
copyStatus.value = 'idle'
}, 2000)
}
}
const goToDesigner = () => {
const metadata = (session.value?.metadata ?? {}) as Record<string, unknown>
const designId = typeof metadata.designId === 'string' ? metadata.designId : null
if (designId) {
router.push({ path: '/', query: { designId } })
} else {
router.push('/')
}
}
</script>
<template>
<main class="min-h-screen bg-slate-950 pb-16">
<AppNavbar />
<section class="mx-auto flex max-w-2xl flex-col gap-6 px-4 pt-16 text-slate-100">
<header class="space-y-4 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-300">
<svg viewBox="0 0 24 24" class="h-10 w-10 fill-current">
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm4.7 7.3-5.2 5.2a1 1 0 0 1-1.4 0l-2-2a1 1 0 0 1 1.4-1.4l1.3 1.29 4.5-4.49a1 1 0 0 1 1.4 1.4Z" />
</svg>
</div>
<div>
<h1 class="text-3xl font-semibold">Payment Confirmed</h1>
<p class="mt-2 text-sm text-slate-400">
Thank you for purchasing your custom slipmat design. We've received your payment and sent a confirmation email.
</p>
</div>
</header>
<div v-if="!sessionId" class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
<p class="text-sm text-slate-300">
We couldn't find a Stripe session ID in the URL. Please return to the designer and try again.
</p>
<button
type="button"
class="mt-4 w-full rounded-xl bg-sky-600 px-4 py-3 text-sm font-medium text-white transition hover:bg-sky-500"
@click="goToDesigner"
>
Back to Designer
</button>
</div>
<div
v-else
class="space-y-6"
>
<div class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
<h2 class="text-lg font-semibold text-white">Order Summary</h2>
<dl class="mt-4 space-y-2 text-sm text-slate-300">
<div class="flex items-center justify-between">
<dt>Total Paid</dt>
<dd>
<span v-if="pending" class="text-slate-500">Loading...</span>
<span v-else-if="amountLabel">{{ amountLabel }}</span>
<span v-else class="text-slate-500">Pending</span>
</dd>
</div>
<div class="flex items-center justify-between">
<dt>Payment Status</dt>
<dd>
<span v-if="pending" class="text-slate-500">Loading...</span>
<span v-else-if="session?.paymentStatus">
{{ session.paymentStatus === 'paid' ? 'Paid' : session.paymentStatus }}
</span>
<span v-else class="text-slate-500">Unknown</span>
</dd>
</div>
<div v-if="session?.customerEmail" class="flex items-center justify-between">
<dt>Receipt sent to</dt>
<dd>{{ session.customerEmail }}</dd>
</div>
<div class="flex items-center justify-between">
<dt>Session ID</dt>
<dd class="flex items-center gap-2 text-xs text-slate-400">
<span class="truncate max-w-[180px] sm:max-w-xs" :title="sessionId">{{ sessionId }}</span>
<button
v-if="supportsClipboard"
type="button"
class="rounded-md border border-slate-700/70 px-2 py-1 text-[11px] uppercase tracking-wide text-slate-200 transition hover:border-slate-500 hover:text-white"
@click="copySessionId"
>
{{ copyStatus === 'copied' ? 'Copied' : 'Copy' }}
</button>
</dd>
</div>
</dl>
<div v-if="transactionStatus === 'saving'" class="mt-4 rounded-xl border border-slate-700/60 bg-slate-800/70 px-4 py-3 text-sm text-slate-200">
Recording your transaction...
</div>
<div v-else-if="transactionStatus === 'error'" class="mt-4 rounded-xl border border-rose-500/50 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
{{ transactionMessage || 'We could not record this transaction. Please contact support with your session ID.' }}
</div>
<div v-else-if="transactionStatus === 'saved'" class="mt-4 rounded-xl border border-emerald-500/50 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-200">
Transaction stored safely.
</div>
<div v-if="error" class="mt-4 rounded-xl border border-rose-500/50 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
{{ error.message || 'We couldn\'t load the session details. Please contact support if this persists.' }}
</div>
</div>
<div class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
<h2 class="text-lg font-semibold text-white">What's next?</h2>
<ul class="mt-4 space-y-3 text-sm text-slate-300">
<li>
We'll email you a confirmation that includes your payment details and a link to download the production-ready files once they're generated.
</li>
<li>
Need to tweak the design? Use the button below to reopen this project; your saved layout will load automatically.
</li>
<li>
Have questions? Reply directly to the confirmation email and our team will help out.
</li>
</ul>
<button
type="button"
class="mt-6 w-full rounded-xl bg-emerald-500 px-4 py-3 text-sm font-semibold text-emerald-950 transition hover:bg-emerald-400"
@click="goToDesigner"
>
Back to Designer
</button>
</div>
</div>
</section>
</main>
</template>

View File

@@ -11,6 +11,7 @@ const {
templates,
selectedTemplate,
selectTemplate,
loadDesign,
displaySize,
templateLabel,
productionPixelSize,
@@ -41,13 +42,214 @@ const {
const DESIGN_PRICE_USD = 39.99;
const { user } = useAuth();
const { user, backendUser, initAuth, isLoading } = useAuth();
const loginModal = useLoginModal();
const route = useRoute();
const router = useRouter();
const config = useRuntimeConfig();
const activeDesignId = ref<string | null>(
typeof route.query.designId === "string" ? route.query.designId : null
);
const loadedDesignId = ref<string | null>(null);
const isDesignLoading = ref(false);
const isCheckoutPending = ref(false);
const checkoutError = ref<string | null>(null);
const lastExportedDesign = ref<ExportedDesign | null>(null);
type LoadDesignInput = Parameters<typeof loadDesign>[0];
type StorageUploadResponse = {
bucket: string;
objectName: string;
publicUrl: string;
presignedUrl?: string | null;
presignedUrlExpiresIn?: number | null;
};
const uploadDesignAsset = async (
file: Blob,
filename: string,
options?: { prefix?: string; bucket?: string }
): Promise<StorageUploadResponse> => {
if (!process.client) {
throw new Error("Asset uploads can only run in the browser context.");
}
const storageUrl = config.public.storageUrl;
if (!storageUrl) {
throw new Error("Storage URL is not configured.");
}
const formData = new FormData();
formData.append("file", file, filename);
if (options?.prefix) {
formData.append("prefix", options.prefix);
}
if (options?.bucket) {
formData.append("bucket", options.bucket);
}
return await $fetch<StorageUploadResponse>("/storage/upload", {
baseURL: storageUrl,
method: "POST",
headers: {
"content-type": "multipart/form-data",
},
body: formData,
});
};
const persistDesign = async (designId: string, design: ExportedDesign) => {
const safeDesignIdBase = designId.replace(/[^a-zA-Z0-9_-]/g, "-") || "design";
const assetBasePath = `designs/${safeDesignIdBase}`;
const timestamp = Date.now();
if (!design.previewBlob || !design.productionBlob) {
throw new Error("Design assets are missing; please export again.");
}
const canvasJsonString =
typeof design.canvasJson === "string"
? design.canvasJson
: JSON.stringify(design.canvasJson);
const prefix = `${assetBasePath}/`;
const [previewUpload, productionUpload, canvasUpload] = await Promise.all([
uploadDesignAsset(design.previewBlob, `preview-${timestamp}.png`, {
prefix,
}),
uploadDesignAsset(design.productionBlob, `production-${timestamp}.png`, {
prefix,
}),
uploadDesignAsset(
new Blob([canvasJsonString], { type: "application/json" }),
`canvas-${timestamp}.json`,
{ prefix }
),
]);
await $fetch("/api/designs", {
method: "POST",
body: {
designId,
templateId: design.templateId,
ownerEmail: user.value?.email ?? null,
ownerId: backendUser.value?.id ?? null,
previewUrl: previewUpload.publicUrl,
productionUrl: productionUpload.publicUrl,
canvasJson: canvasUpload.publicUrl,
metadata: {
designName: templateLabel.value,
storage: {
preview: {
objectName: previewUpload.objectName,
bucket: previewUpload.bucket,
},
production: {
objectName: productionUpload.objectName,
bucket: productionUpload.bucket,
},
canvas: {
objectName: canvasUpload.objectName,
bucket: canvasUpload.bucket,
},
},
storagePaths: {
preview: previewUpload.objectName,
production: productionUpload.objectName,
canvas: canvasUpload.objectName,
},
},
},
});
return {
previewUrl: previewUpload.publicUrl,
productionUrl: productionUpload.publicUrl,
canvasJsonUrl: canvasUpload.publicUrl,
};
};
const loadDesignById = async (designId: string) => {
if (!process.client) {
return;
}
isDesignLoading.value = true;
try {
const design = await $fetch<{
designId?: string;
templateId?: string | null;
previewUrl?: string | null;
productionUrl?: string | null;
canvasJson?: unknown;
}>(`/api/designs/${designId}`);
if (!design?.canvasJson) {
throw new Error("Saved design is missing canvas data.");
}
let canvasPayload: LoadDesignInput["canvasJson"] =
design.canvasJson as LoadDesignInput["canvasJson"];
if (typeof design.canvasJson === "string") {
const trimmed = design.canvasJson.trim();
const isRemoteSource = /^https?:\/\//i.test(trimmed);
if (isRemoteSource) {
const response = await fetch(trimmed);
if (!response.ok) {
throw new Error("Failed to download saved canvas data.");
}
canvasPayload =
(await response.text()) as LoadDesignInput["canvasJson"];
} else {
canvasPayload = trimmed as LoadDesignInput["canvasJson"];
}
}
await loadDesign({
templateId: design.templateId ?? null,
canvasJson: canvasPayload,
previewUrl: design.previewUrl ?? null,
});
loadedDesignId.value = designId;
lastExportedDesign.value = null;
} catch (error: any) {
console.error("Failed to load saved design", error);
} finally {
isDesignLoading.value = false;
}
};
watch(
() => route.query.designId,
(value) => {
const nextId = typeof value === "string" ? value : null;
if (nextId !== activeDesignId.value) {
activeDesignId.value = nextId;
}
}
);
watch(
activeDesignId,
(designId) => {
if (!designId || designId === loadedDesignId.value) {
return;
}
loadDesignById(designId);
},
{ immediate: true }
);
onMounted(() => {
initAuth();
});
const handleTemplateSelect = (templateId: string) => {
selectTemplate(templateId);
};
@@ -64,6 +266,18 @@ const handleCheckout = async () => {
return;
}
initAuth();
if (isLoading.value) {
for (
let attempt = 0;
attempt < 10 && isLoading.value && !user.value;
attempt += 1
) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
if (!user.value) {
loginModal.value = true;
return;
@@ -83,26 +297,42 @@ const handleCheckout = async () => {
lastExportedDesign.value = exportResult;
const designId =
typeof crypto !== "undefined" && "randomUUID" in crypto
activeDesignId.value ??
(typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `design-${Date.now()}`;
: `design-${Date.now()}`);
await persistDesign(designId, exportResult);
activeDesignId.value = designId;
loadedDesignId.value = designId;
if (
typeof route.query.designId !== "string" ||
route.query.designId !== designId
) {
await router.replace({ query: { designId } });
}
const successUrlTemplate = `${window.location.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = window.location.href;
const session = await $fetch<{ id: string; url?: string | null }>("/api/checkout.session", {
method: "POST",
body: {
designId,
templateId: exportResult.templateId,
designName: templateLabel.value,
amount: DESIGN_PRICE_USD,
currency: "usd",
successUrl: successUrlTemplate,
cancelUrl,
customerEmail: user.value?.email,
},
});
const session = await $fetch<{ id: string; url?: string | null }>(
"/api/checkout.session",
{
method: "POST",
body: {
designId,
templateId: exportResult.templateId,
designName: templateLabel.value,
amount: DESIGN_PRICE_USD,
currency: "usd",
successUrl: successUrlTemplate,
cancelUrl,
customerEmail: user.value?.email ?? undefined,
},
}
);
if (!session?.id) {
throw new Error("Stripe session could not be created.");
@@ -113,11 +343,15 @@ const handleCheckout = async () => {
return;
}
const fallbackRedirect = successUrlTemplate.replace("{CHECKOUT_SESSION_ID}", session.id);
const fallbackRedirect = successUrlTemplate.replace(
"{CHECKOUT_SESSION_ID}",
session.id
);
window.location.href = fallbackRedirect;
} catch (err: any) {
console.error("Checkout failed", err);
checkoutError.value = err?.message ?? "Unable to start checkout. Please try again.";
checkoutError.value =
err?.message ?? "Unable to start checkout. Please try again.";
} finally {
isCheckoutPending.value = false;
}
@@ -169,7 +403,7 @@ const handleCheckout = async () => {
:preview-url="previewUrl"
:template-label="templateLabel"
:production-pixels="productionPixelSize"
:is-exporting="isExporting"
:is-exporting="isExporting || isDesignLoading"
:is-checkout-pending="isCheckoutPending"
:checkout-price="DESIGN_PRICE_USD"
:checkout-error="checkoutError"
@@ -181,7 +415,9 @@ const handleCheckout = async () => {
</div>
<div class="flex flex-col gap-6">
<div class="rounded-3xl border border-slate-800/60 bg-linear-to-br from-slate-900 via-slate-950 to-black p-6 shadow-2xl shadow-slate-950/60">
<div
class="rounded-3xl border border-slate-800/60 bg-linear-to-br from-slate-900 via-slate-950 to-black p-6 shadow-2xl shadow-slate-950/60"
>
<DesignerCanvas
:size="displaySize"
:background-color="selectedTemplate.backgroundColor"
@@ -190,8 +426,8 @@ const handleCheckout = async () => {
/>
<p class="mt-4 text-sm text-slate-400">
Safe zone and bleed guides update automatically when you switch
templates. Use the toolbar to layer text, shapes, and imagery inside the
circular boundary.
templates. Use the toolbar to layer text, shapes, and imagery
inside the circular boundary.
</p>
</div>
</div>

227
app/pages/orders.vue Normal file
View File

@@ -0,0 +1,227 @@
<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>

156
app/pages/profile.vue Normal file
View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
const router = useRouter();
const loginModal = useLoginModal();
const { user, backendUser, initAuth, isLoading, signOut } = useAuth();
onMounted(() => {
initAuth();
});
const isAuthenticated = computed(() => Boolean(user.value));
const displayName = computed(() => {
return (
backendUser.value?.name ||
user.value?.displayName ||
backendUser.value?.email ||
user.value?.email ||
"Slipmat Creator"
);
});
const displayEmail = computed(() => backendUser.value?.email || user.value?.email || "Unknown");
const displayId = computed(() => backendUser.value?.id || user.value?.uid || null);
const lastLogin = computed(() => {
const raw =
backendUser.value?.lastLogin ||
backendUser.value?.updatedAt ||
user.value?.metadata?.lastSignInTime ||
user.value?.metadata?.creationTime ||
null;
return raw ? new Date(raw) : null;
});
const profileFields = computed(() => {
const entries: Array<{ label: string; value: string | null }> = [
{ label: "Display name", value: displayName.value },
{ label: "Email", value: displayEmail.value },
];
if (displayId.value) {
entries.push({ label: "User ID", value: displayId.value });
}
if (lastLogin.value) {
entries.push({ label: "Last login", value: lastLogin.value.toLocaleString() });
}
if (backendUser.value?.role) {
entries.push({ label: "Role", value: String(backendUser.value.role) });
}
if (backendUser.value?.createdAt) {
entries.push({ label: "Created", value: new Date(backendUser.value.createdAt).toLocaleString() });
}
return entries;
});
const openLogin = () => {
loginModal.value = true;
router.push("/");
};
const handleSignOut = async () => {
try {
await signOut();
router.push("/");
} catch (error) {
console.error("Sign out failed", error);
}
};
</script>
<template>
<main class="min-h-screen bg-slate-950 pb-16 text-slate-100">
<AppNavbar />
<section class="mx-auto flex max-w-3xl 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">Profile</h1>
<p class="text-sm text-slate-400">
View your Slipmatz account details and manage sessions. Changes to your profile are controlled through your authentication provider.
</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">You're signed out</h2>
<p class="mt-2 text-sm text-slate-400">
Sign in to view your profile information and order history.
</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="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-white">{{ displayName }}</h2>
<p class="text-sm text-slate-400">{{ displayEmail }}</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<NuxtLink
to="/orders"
class="rounded-md border border-slate-700/70 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-sky-500 hover:text-white"
>
View order history
</NuxtLink>
<button
type="button"
class="rounded-md border border-rose-500/70 px-4 py-2 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/10"
@click="handleSignOut"
>
Sign out
</button>
</div>
</div>
</div>
<div class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
<h3 class="text-lg font-semibold text-white">Account details</h3>
<dl class="mt-4 grid gap-4 text-sm text-slate-300 sm:grid-cols-2">
<div v-for="field in profileFields" :key="field.label" class="space-y-1">
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">{{ field.label }}</dt>
<dd class="text-sm text-slate-200 break-all">{{ field.value }}</dd>
</div>
</dl>
</div>
<div v-if="backendUser" class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
<h3 class="text-lg font-semibold text-white">Backend session</h3>
<p class="text-sm text-slate-400">
The following data is provided by the Slipmatz backend and may include additional metadata used for order fulfillment.
</p>
<pre class="mt-4 overflow-x-auto rounded-xl bg-slate-950/80 p-4 text-xs text-slate-300">
{{ JSON.stringify(backendUser, null, 2) }}
</pre>
</div>
</div>
</section>
</main>
</template>

186
app/pages/register.vue Normal file
View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
const router = useRouter();
const { user, registerWithEmail, signInWithGoogle, isLoading, error } = useAuth();
const loginModal = useLoginModal();
const email = ref("");
const password = ref("");
const confirmPassword = ref("");
const isSubmitting = ref(false);
const localError = ref<string | null>(null);
const isProcessing = computed(() => isSubmitting.value || isLoading.value);
const combinedError = computed(() => localError.value || error.value || null);
const redirectIfAuthenticated = (maybeUser: unknown) => {
if (!process.client) {
return;
}
if (maybeUser) {
router.replace("/");
}
};
watch(
() => user.value,
(currentUser) => {
if (currentUser) {
redirectIfAuthenticated(currentUser);
}
},
{ immediate: true }
);
const handleRegister = async () => {
if (!process.client) {
return;
}
localError.value = null;
if (!email.value.trim()) {
localError.value = "Email is required.";
return;
}
if (password.value.length < 6) {
localError.value = "Password must be at least 6 characters.";
return;
}
if (password.value !== confirmPassword.value) {
localError.value = "Passwords do not match.";
return;
}
try {
isSubmitting.value = true;
await registerWithEmail(email.value.trim(), password.value);
await router.replace("/");
} catch (err: any) {
localError.value = err?.message ?? "Registration failed. Please try again.";
} finally {
isSubmitting.value = false;
}
};
const handleGoogleRegister = async () => {
if (!process.client) {
return;
}
try {
isSubmitting.value = true;
await signInWithGoogle();
await router.replace("/");
} catch (err: any) {
localError.value = err?.message ?? "Google sign-in failed. Please try again.";
} finally {
isSubmitting.value = false;
}
};
const goToSignIn = async () => {
if (!process.client) {
return;
}
loginModal.value = true;
await 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-md flex-col gap-8 px-4 pt-16">
<header class="space-y-3 text-center">
<p class="text-sm uppercase tracking-[0.35em] text-sky-400">Create Account</p>
<h1 class="text-3xl font-semibold text-white">Join Slipmatz</h1>
<p class="text-sm text-slate-400">
Sign up with email and password to save your designs and return to them anytime.
</p>
</header>
<form class="space-y-5 rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6" @submit.prevent="handleRegister">
<div class="space-y-1">
<label for="email" class="block text-sm font-medium text-slate-300">Email</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="email"
class="w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40"
/>
</div>
<div class="space-y-1">
<label for="password" class="block text-sm font-medium text-slate-300">Password</label>
<input
id="password"
v-model="password"
type="password"
required
autocomplete="new-password"
minlength="6"
class="w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40"
/>
<p class="text-xs text-slate-500">Minimum 6 characters.</p>
</div>
<div class="space-y-1">
<label for="confirm-password" class="block text-sm font-medium text-slate-300">Confirm Password</label>
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
required
autocomplete="new-password"
minlength="6"
class="w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40"
/>
</div>
<button
type="submit"
:disabled="isProcessing"
class="w-full rounded-md bg-emerald-500 px-4 py-2 text-sm font-semibold text-emerald-950 transition hover:bg-emerald-400 disabled:opacity-60"
>
{{ isProcessing ? "Creating account..." : "Create account" }}
</button>
<div class="my-2 flex items-center gap-3 text-xs uppercase tracking-[0.2em] text-slate-500">
<div class="flex-1 border-t border-slate-800"></div>
<span>or</span>
<div class="flex-1 border-t border-slate-800"></div>
</div>
<button
type="button"
:disabled="isProcessing"
class="w-full rounded-md border border-slate-700 bg-slate-900 px-4 py-2 text-sm font-semibold text-slate-200 transition hover:bg-slate-800 disabled:opacity-60"
@click="handleGoogleRegister"
>
Continue with Google
</button>
<p v-if="combinedError" class="rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{{ combinedError }}
</p>
</form>
<p class="text-center text-sm text-slate-400">
Already have an account?
<NuxtLink
to="/"
class="text-sky-400 hover:text-sky-300"
@click.prevent="goToSignIn"
>
Sign in instead
</NuxtLink>
</p>
</section>
</main>
</template>