first commit
Some checks failed
Deploy Production / deploy (push) Has been cancelled

This commit is contained in:
Frank John Begornia
2026-01-12 22:16:36 +08:00
commit 3ba0b250ed
44 changed files with 18635 additions and 0 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-white pb-16">
<AppNavbar />
<section class="mx-auto flex max-w-2xl flex-col gap-6 px-4 pt-16 text-slate-900">
<header class="space-y-4 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
<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 text-slate-900">Payment Confirmed</h1>
<p class="mt-2 text-sm text-slate-600">
Thank you for purchasing your custom jersey design. We've received your payment and sent a confirmation email.
</p>
</div>
</header>
<div v-if="!sessionId" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<p class="text-sm text-slate-700">
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 border-2 border-slate-900 bg-slate-900 px-4 py-3 text-sm font-medium text-white transition hover:bg-white hover:text-slate-900"
@click="goToDesigner"
>
Back to Designer
</button>
</div>
<div
v-else
class="space-y-6"
>
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">Order Summary</h2>
<dl class="mt-4 space-y-2 text-sm text-slate-700">
<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" class="font-semibold text-slate-900">{{ 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" class="font-semibold text-slate-900">
{{ 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 class="font-semibold text-slate-900">{{ 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-600">
<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-300 px-2 py-1 text-[11px] uppercase tracking-wide text-slate-700 transition hover:border-slate-900 hover:text-slate-900"
@click="copySessionId"
>
{{ copyStatus === 'copied' ? 'Copied' : 'Copy' }}
</button>
</dd>
</div>
</dl>
<div v-if="transactionStatus === 'saving'" class="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
Recording your transaction...
</div>
<div v-else-if="transactionStatus === 'error'" class="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ 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-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
Transaction stored safely.
</div>
<div v-if="error" class="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ error.message || 'We couldn\'t load the session details. Please contact support if this persists.' }}
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">What's next?</h2>
<ul class="mt-4 space-y-3 text-sm text-slate-700">
<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 border-2 border-emerald-600 bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-white hover:text-emerald-600"
@click="goToDesigner"
>
Back to Designer
</button>
</div>
</div>
</section>
</main>
</template>

441
app/pages/designer.vue Normal file
View File

@@ -0,0 +1,441 @@
<script setup lang="ts">
import DesignerCanvas from "~/components/designer/DesignerCanvas.vue";
import DesignerPreview from "~/components/designer/DesignerPreview.vue";
import DesignerToolbar from "~/components/designer/DesignerToolbar.vue";
import TemplatePicker from "~/components/designer/TemplatePicker.vue";
import { useSlipmatDesigner } from "~/composables/useSlipmatDesigner";
import type { ExportedDesign } from "~/composables/useSlipmatDesigner";
const {
templates,
selectedTemplate,
selectTemplate,
loadDesign,
displaySize,
templateLabel,
productionPixelSize,
previewUrl,
registerCanvas,
unregisterCanvas,
addTextbox,
addShape,
addImageFromFile,
clearDesign,
downloadPreview,
downloadProduction,
exportDesign,
isExporting,
activeFillColor,
activeStrokeColor,
canStyleSelection,
setActiveFillColor,
setActiveStrokeColor,
setBackgroundColor,
zoomLevel,
minZoom,
maxZoom,
setZoom,
zoomIn,
zoomOut,
resetZoom,
} = useSlipmatDesigner();
const DESIGN_PRICE_USD = 39.99;
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 backendUrl = config.public.backendUrl;
if (!backendUrl) {
throw new Error("Backend 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: backendUrl,
method: "POST",
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 }
),
]);
// Get Firebase ID token for authentication
const idToken = user.value ? await user.value.getIdToken() : null;
await $fetch("/api/designs", {
method: "POST",
headers: idToken ? { Authorization: `Bearer ${idToken}` } : {},
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);
};
const handleExport = async () => {
const result = await exportDesign();
if (result) {
lastExportedDesign.value = result;
}
};
const handleCheckout = async () => {
if (!process.client) {
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;
}
checkoutError.value = null;
isCheckoutPending.value = true;
try {
let exportResult = await exportDesign();
if (!exportResult) {
exportResult = lastExportedDesign.value;
}
if (!exportResult) {
throw new Error("Unable to export the current design. Please try again.");
}
lastExportedDesign.value = exportResult;
const designId =
activeDesignId.value ??
(typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `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 ?? undefined,
},
}
);
if (!session?.id) {
throw new Error("Stripe session could not be created.");
}
if (session.url) {
window.location.href = session.url;
return;
}
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.";
} finally {
isCheckoutPending.value = false;
}
};
</script>
<template>
<main class="min-h-screen bg-white pb-16 text-slate-900">
<AppNavbar />
<div class="mx-auto max-w-6xl px-4 pt-12 sm:px-6 lg:px-8">
<header class="space-y-3">
<p class="text-sm uppercase tracking-[0.35em] text-slate-600">
TableJerseys Designer
</p>
<h1 class="text-3xl font-bold text-slate-900 sm:text-4xl">
Craft custom jerseys ready for your table.
</h1>
</header>
<section class="mt-10 flex flex-col gap-8 lg:grid lg:grid-cols-[320px_minmax(0,1fr)] lg:gap-6">
<!-- Left Sidebar - Template Picker and Preview (together on desktop, separate on mobile) -->
<div class="contents lg:block lg:space-y-6">
<div class="order-1">
<TemplatePicker
:templates="templates"
:selected-template-id="selectedTemplate.id"
@select="handleTemplateSelect"
/>
</div>
<div class="order-3">
<DesignerPreview
:is-checkout-pending="isCheckoutPending"
:checkout-price="DESIGN_PRICE_USD"
:checkout-error="checkoutError"
@checkout="handleCheckout"
/>
</div>
</div>
<!-- Designer Canvas - Second on mobile, right column on desktop -->
<div class="order-2 flex flex-col gap-6 lg:order-0">
<div
class="rounded-3xl border border-slate-200 bg-white shadow-xl"
>
<DesignerToolbar
:on-add-text="addTextbox"
:on-add-circle="() => addShape('circle')"
:on-add-rectangle="() => addShape('rect')"
:on-clear="clearDesign"
:on-import-image="addImageFromFile"
:on-fill-change="setActiveFillColor"
:on-stroke-change="setActiveStrokeColor"
:on-background-change="setBackgroundColor"
:active-fill="activeFillColor"
:active-stroke="activeStrokeColor"
:active-background="selectedTemplate.backgroundColor"
:can-style-selection="canStyleSelection"
:zoom="zoomLevel"
:min-zoom="minZoom"
:max-zoom="maxZoom"
:on-zoom-change="setZoom"
:on-zoom-in="zoomIn"
:on-zoom-out="zoomOut"
:on-zoom-reset="resetZoom"
/>
<div class="p-6">
<DesignerCanvas
:size="displaySize"
:background-color="selectedTemplate.backgroundColor"
:register-canvas="registerCanvas"
:unregister-canvas="unregisterCanvas"
/>
<p class="mt-4 text-sm text-slate-600">
Safe zone and bleed guides update automatically when you switch
templates. Use the toolbar to layer text, shapes, and imagery
inside the design area.
</p>
</div>
</div>
</div>
</section>
</div>
</main>
</template>

148
app/pages/index.vue Normal file
View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
const router = useRouter();
const startDesigning = () => {
router.push('/designer');
};
</script>
<template>
<div class="relative min-h-screen overflow-hidden bg-white">
<!-- Subtle Background Pattern -->
<div class="absolute inset-0 opacity-5">
<div class="absolute inset-0" style="background-image: radial-gradient(circle at 1px 1px, rgb(156 163 175) 1px, transparent 0); background-size: 40px 40px;"></div>
</div>
<!-- Main Content Grid -->
<div class="relative z-20 flex min-h-screen flex-col px-4 py-12 md:px-8 lg:px-16">
<!-- Top Section - Split Layout -->
<div class="flex flex-1 flex-col items-center justify-between gap-8 lg:flex-row lg:gap-16">
<!-- Left Side - Title & Description -->
<div class="w-full space-y-8 text-center lg:w-1/2 lg:text-left">
<h1 class="text-6xl font-bold tracking-tight text-slate-900 sm:text-7xl md:text-8xl">
TableJerseys
</h1>
<div class="space-y-4">
<p class="text-2xl font-semibold text-slate-700 sm:text-3xl md:text-4xl">
Design custom jerseys for your table
</p>
<!-- Simple preview for mobile view -->
<div class="relative mx-auto my-8 block lg:hidden">
<div class="relative mx-auto h-[280px] w-[280px] sm:h-80 sm:w-80">
<div class="flex h-full w-full items-center justify-center rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-slate-50 to-slate-100 shadow-lg overflow-hidden">
<!-- Rotating Table with Fitted Cover -->
<div class="animate-spin-slow relative" style="transform-style: preserve-3d;">
<div class="relative">
<!-- Table with fitted cover -->
<div class="relative">
<!-- Table Top -->
<div class="relative h-24 w-40 rounded-t-sm bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900 shadow-xl">
<!-- Jersey design on top of cover -->
<div class="absolute inset-4 flex items-center justify-center rounded-lg bg-gradient-to-br from-blue-400 via-white to-red-400 shadow-lg border-2 border-slate-300">
<div class="text-center">
<div class="text-2xl font-bold text-slate-800">23</div>
<div class="text-[7px] font-semibold text-slate-600 tracking-wider">CUSTOM</div>
</div>
</div>
</div>
<!-- Fitted Cover Skirt (draping down) -->
<div class="absolute top-24 left-0 right-0 h-16 bg-gradient-to-b from-slate-800 via-slate-900 to-slate-950 rounded-b-sm shadow-2xl">
<!-- Fabric folds/creases -->
<div class="absolute inset-0 opacity-30" style="background: repeating-linear-gradient(90deg, transparent, transparent 8px, rgba(0,0,0,0.3) 8px, rgba(0,0,0,0.3) 9px);"></div>
<!-- Shadow at bottom -->
<div class="absolute bottom-0 left-0 right-0 h-2 bg-black/40"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<p class="text-base text-slate-600 sm:text-lg">
Create professional, print-ready jersey designs in minutes
</p>
<p class="text-sm text-slate-500 sm:text-base">
Perfect for sports teams, events, and table enthusiasts.
</p>
</div>
<div>
<NuxtLink
to="/designer"
class="group relative inline-flex overflow-hidden rounded-lg border-2 border-slate-900 bg-slate-900 px-12 py-5 text-xl font-bold text-white shadow-lg transition-all hover:bg-white hover:text-slate-900 active:scale-95"
>
<span class="relative z-10 flex items-center gap-3">
Start Designing
<svg class="h-6 w-6 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</span>
</NuxtLink>
</div>
<!-- Stats Section - Now on Left Side -->
<div class="mx-auto grid max-w-4xl gap-8 pt-12 grid-cols-3 sm:grid-cols-3 lg:mx-0">
<div class="space-y-2 text-center lg:text-left">
<div class="text-3xl font-bold text-slate-900 md:text-4xl">Custom</div>
<div class="text-sm text-slate-600">Any Size</div>
</div>
<div class="space-y-2 text-center lg:text-left">
<div class="text-3xl font-bold text-slate-900 md:text-4xl">300 DPI</div>
<div class="text-sm text-slate-600">Print Quality</div>
</div>
<div class="space-y-2 text-center lg:text-left">
<div class="text-3xl font-bold text-slate-900 md:text-4xl">$39.99</div>
<div class="text-sm text-slate-600">Per Design</div>
</div>
</div>
</div>
<!-- Right Side - Simple Image -->
<div class="relative hidden w-full lg:block lg:w-1/2">
<div class="relative mx-auto max-w-4xl">
<div class="relative mx-auto h-[300px] w-[300px] sm:h-[600px] sm:w-[600px]">
<div class="flex h-full w-full items-center justify-center rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-slate-50 to-slate-100 shadow-xl overflow-hidden">
<!-- Rotating Table with Fitted Cover -->
<div class=" relative" style="transform-style: preserve-3d;">
<div class="relative">
<!-- Table with fitted cover -->
<div class="relative">
<!-- Table Top with jersey on cover -->
<div class="relative h-48 w-96 sm:h-64 sm:w-[500px] rounded-t-lg bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900 shadow-2xl">
<!-- Jersey design on top of cover -->
<div class="absolute inset-8 sm:inset-12 flex items-center justify-center rounded-xl bg-gradient-to-br from-blue-400 via-white to-red-400 shadow-2xl border-4 border-slate-300">
<div class="absolute inset-4 sm:inset-6 flex items-center justify-center rounded-lg bg-gradient-to-br from-blue-100 via-white to-red-100 border-2 border-slate-200">
<div class="text-center">
<div class="text-5xl sm:text-7xl font-bold text-slate-800">23</div>
<div class="text-sm sm:text-xl font-semibold text-slate-600 tracking-wider">CUSTOM</div>
</div>
</div>
</div>
<!-- Subtle highlights on cover top -->
<div class="absolute top-4 left-8 h-6 w-12 rounded-full bg-white/10 blur-lg"></div>
</div>
<!-- Fitted Cover Skirt (draping down like trade show table) -->
<div class="absolute top-48 sm:top-64 left-0 right-0 h-32 sm:h-40 bg-gradient-to-b from-slate-800 via-slate-900 to-slate-950 rounded-b-lg shadow-2xl">
<!-- Fabric folds/pleats -->
<div class="absolute inset-0 opacity-40" style="background: repeating-linear-gradient(90deg, transparent, transparent 16px, rgba(0,0,0,0.4) 16px, rgba(0,0,0,0.4) 18px);"></div>
<!-- More realistic vertical folds -->
<div class="absolute inset-0 opacity-20" style="background: repeating-linear-gradient(90deg, rgba(255,255,255,0.05) 0px, transparent 2px, transparent 14px, rgba(0,0,0,0.3) 16px);"></div>
<!-- Shadow at bottom edge -->
<div class="absolute bottom-0 left-0 right-0 h-4 bg-black/50 rounded-b-lg"></div>
<!-- Ground shadow -->
<div class="absolute -bottom-2 left-4 right-4 h-2 bg-black/30 blur-sm rounded-full"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

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-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>

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-white pb-16 text-slate-900">
<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-slate-600">Account</p>
<h1 class="text-3xl font-semibold text-slate-900">Profile</h1>
<p class="text-sm text-slate-600">
View your TableJerseys 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-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">You're signed out</h2>
<p class="mt-2 text-sm text-slate-600">
Sign in to view your profile information and order history.
</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="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-slate-900">{{ displayName }}</h2>
<p class="text-sm text-slate-600">{{ displayEmail }}</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<NuxtLink
to="/orders"
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-slate-900 hover:text-slate-900"
>
View order history
</NuxtLink>
<button
type="button"
class="rounded-md border border-rose-300 px-4 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-50"
@click="handleSignOut"
>
Sign out
</button>
</div>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h3 class="text-lg font-semibold text-slate-900">Account details</h3>
<dl class="mt-4 grid gap-4 text-sm text-slate-700 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-900 break-all">{{ field.value }}</dd>
</div>
</dl>
</div>
<div v-if="backendUser" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h3 class="text-lg font-semibold text-slate-900">Backend session</h3>
<p class="text-sm text-slate-600">
The following data is provided by the TableJerseys backend and may include additional metadata used for order fulfillment.
</p>
<pre class="mt-4 overflow-x-auto rounded-xl bg-slate-50 p-4 text-xs text-slate-700">
{{ 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-white pb-16 text-slate-900">
<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-slate-600">Create Account</p>
<h1 class="text-3xl font-semibold text-slate-900">Join TableJerseys</h1>
<p class="text-sm text-slate-600">
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-200 bg-white p-6 shadow-sm" @submit.prevent="handleRegister">
<div class="space-y-1">
<label for="email" class="block text-sm font-medium text-slate-700">Email</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="email"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
/>
</div>
<div class="space-y-1">
<label for="password" class="block text-sm font-medium text-slate-700">Password</label>
<input
id="password"
v-model="password"
type="password"
required
autocomplete="new-password"
minlength="6"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
/>
<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-700">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-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
/>
</div>
<button
type="submit"
:disabled="isProcessing"
class="w-full 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 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-200"></div>
<span>or</span>
<div class="flex-1 border-t border-slate-200"></div>
</div>
<button
type="button"
:disabled="isProcessing"
class="w-full rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:opacity-60"
@click="handleGoogleRegister"
>
Continue with Google
</button>
<p v-if="combinedError" class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{{ combinedError }}
</p>
</form>
<p class="text-center text-sm text-slate-600">
Already have an account?
<NuxtLink
to="/"
class="text-slate-900 font-semibold hover:text-slate-700"
@click.prevent="goToSignIn"
>
Sign in instead
</NuxtLink>
</p>
</section>
</main>
</template>