Merge pull request #1 from franknstayn/feat/dev

Feat/dev
This commit is contained in:
franknstayn
2025-11-21 10:35:59 +08:00
committed by GitHub
28 changed files with 3727 additions and 338 deletions

47
.dockerignore Normal file
View File

@@ -0,0 +1,47 @@
# Nuxt build artifacts
.output
.nuxt
dist
# Node modules
node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment files
.env
.env.local
.env.*.local
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
.dockerignore
docker-compose.yml
# Testing
coverage
.nyc_output
# Misc
*.md
!README.md

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
# Firebase Configuration
# Get these values from your Firebase Console -> Project Settings -> General -> Your apps
NUXT_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key_here
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_firebase_auth_domain_here
NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_firebase_project_id_here
# Use the bucket ID (e.g. project-id.appspot.com), not the web URL.
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_firebase_storage_bucket_here
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id_here
NUXT_PUBLIC_FIREBASE_APP_ID=your_firebase_app_id_here
NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID=your_firebase_measurement_id_here
# Stripe Configuration
NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# Backend Configuration
NUXT_PUBLIC_BACKEND_URL=http://localhost:3000
NUXT_PUBLIC_STORAGE_URL=http://localhost:9000

43
Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies with clean cache
RUN npm ci --prefer-offline --no-audit
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install only production dependencies with clean cache
RUN npm ci --prefer-offline --no-audit --omit=dev
# Copy built application from builder stage
COPY --from=builder /app/.output ./.output
# Expose port
EXPOSE 3000
# Set environment to production
ENV NODE_ENV=production
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start the application
CMD ["node", ".output/server/index.mjs"]

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
const { user, backendUser, signOut, initAuth, isLoading } = useAuth()
const loginModal = useLoginModal()
const route = useRoute()
const dropdownRef = ref<HTMLElement | null>(null)
const showMenu = ref(false)
const displayName = computed(() => backendUser.value?.name || user.value?.displayName || backendUser.value?.email || user.value?.email || "Account")
const avatarInitials = computed(() => {
const source = displayName.value
if (!source) {
return "S"
}
const parts = source.trim().split(/\s+/)
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase()
}
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
})
const openLoginModal = () => {
loginModal.value = true
}
const handleSignOut = async () => {
try {
await signOut()
showMenu.value = false
} catch (error) {
console.error('Sign out failed:', error)
}
}
const toggleMenu = () => {
showMenu.value = !showMenu.value
}
const closeMenu = () => {
if (showMenu.value) {
showMenu.value = false
}
}
const handleClickOutside = (event: MouseEvent) => {
if (!dropdownRef.value) {
return
}
if (!dropdownRef.value.contains(event.target as Node)) {
closeMenu()
}
}
onMounted(() => {
initAuth()
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
watch(
() => route.fullPath,
() => {
closeMenu()
}
)
</script>
<template>
<nav class="sticky top-0 z-30 border-b border-slate-800/60 bg-slate-950/80 backdrop-blur">
<div class="mx-auto max-w-6xl px-4 py-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between gap-6">
<NuxtLink to="/" class="text-lg font-semibold text-white transition hover:text-sky-300 sm:text-xl">
Slipmatz
</NuxtLink>
<div class="flex items-center gap-3">
<!-- Show user info and logout when authenticated -->
<div v-if="user && !isLoading" ref="dropdownRef" class="relative flex items-center gap-3">
<span class="hidden text-sm text-slate-300 sm:inline">{{ backendUser?.email || user.email }}</span>
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-full bg-slate-800 text-sm font-semibold uppercase text-slate-200 transition hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
@click.stop="toggleMenu"
aria-haspopup="menu"
:aria-expanded="showMenu"
>
{{ avatarInitials }}
</button>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="showMenu"
class="absolute right-0 top-12 w-56 rounded-2xl border border-slate-800/70 bg-slate-900/95 p-3 shadow-xl shadow-slate-950/50 backdrop-blur"
role="menu"
>
<p class="px-3 text-xs uppercase tracking-[0.25em] text-slate-500">Signed in as</p>
<p class="px-3 text-sm font-medium text-slate-200">{{ displayName }}</p>
<p class="px-3 text-xs text-slate-500">{{ backendUser?.email || user.email }}</p>
<div class="my-3 h-px bg-slate-800"></div>
<NuxtLink
to="/profile"
class="flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-slate-200 transition hover:bg-slate-800"
role="menuitem"
>
Profile
</NuxtLink>
<NuxtLink
to="/orders"
class="flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-slate-200 transition hover:bg-slate-800"
role="menuitem"
>
Orders
</NuxtLink>
<button
type="button"
class="mt-2 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-rose-300 transition hover:bg-rose-500/10"
@click="handleSignOut"
role="menuitem"
>
Logout
</button>
</div>
</transition>
</div>
<!-- Show login button when not authenticated -->
<div v-else-if="!isLoading" class="flex items-center gap-3">
<button
type="button"
@click="openLoginModal"
class="rounded-full border border-slate-700/80 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-slate-500 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
>
Login
</button>
</div>
<!-- Loading state -->
<div v-else class="flex items-center gap-3">
<div class="h-8 w-16 animate-pulse rounded bg-slate-700"></div>
</div>
</div>
</div>
</div>
<LoginModal />
</nav>
</template>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
const { signInWithEmail, signInWithGoogle, error } = useAuth();
const isOpen = useLoginModal();
const email = ref("");
const password = ref("");
const loginError = ref<string | null>(null);
const isSubmitting = ref(false);
const handleEmailLogin = async () => {
try {
loginError.value = null;
isSubmitting.value = true;
await signInWithEmail(email.value, password.value);
isOpen.value = false;
// Reset form
email.value = "";
password.value = "";
} catch (err) {
loginError.value = "Login failed. Please check your credentials.";
} finally {
isSubmitting.value = false;
}
};
const handleGoogleLogin = async () => {
try {
loginError.value = null;
isSubmitting.value = true;
await signInWithGoogle();
isOpen.value = false;
} catch (err) {
loginError.value = "Google login failed. Please try again.";
} finally {
isSubmitting.value = false;
}
};
</script>
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed left-0 top-0 z-50 flex min-h-screen w-full items-center justify-center bg-black/60 px-4"
@click.self="isOpen = false"
>
<div class="w-full max-w-md rounded-lg bg-slate-900 p-6 shadow-xl">
<h2 class="mb-6 text-2xl font-bold text-white">Sign In</h2>
<form @submit.prevent="handleEmailLogin" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300"
>Email</label
>
<input
v-model="email"
type="email"
required
class="mt-1 w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-300"
>Password</label
>
<input
v-model="password"
type="password"
required
class="mt-1 w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
/>
</div>
<button
type="submit"
:disabled="isSubmitting"
class="w-full rounded-md bg-sky-600 px-4 py-2 text-white hover:bg-sky-700 disabled:opacity-50"
>
{{ isSubmitting ? "Signing in..." : "Sign In" }}
</button>
</form>
<div class="my-4 flex items-center">
<div class="flex-1 border-t border-slate-700"></div>
<span class="px-3 text-sm text-slate-400">or</span>
<div class="flex-1 border-t border-slate-700"></div>
</div>
<button
@click="handleGoogleLogin"
:disabled="isSubmitting"
class="w-full rounded-md border border-slate-700 bg-slate-800 px-4 py-2 text-white hover:bg-slate-700 disabled:opacity-50"
>
Sign in with Google
</button>
<div v-if="loginError || error" class="mt-4 text-sm text-red-400">
{{ loginError || error }}
</div>
<p class="mt-4 text-sm text-slate-400">
Need an account?
<NuxtLink
to="/register"
class="text-sky-400 hover:text-sky-300"
@click="isOpen = false"
>
Create one instead
</NuxtLink>
</p>
<button
@click="isOpen = false"
class="mt-4 text-sm text-slate-400 hover:text-white"
>
Cancel
</button>
</div>
</div>
</Teleport>
</template>

View File

@@ -1,117 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ const props = defineProps<{
previewUrl: string | null; isCheckoutPending: boolean;
templateLabel: string; checkoutPrice: number;
productionPixels: number; checkoutError: string | null;
isExporting: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "download-preview"): void; (e: "checkout"): void;
(e: "download-production"): void;
(e: "export"): void;
}>(); }>();
const viewMode = ref<"flat" | "turntable">("flat"); const handleCheckout = () => emit("checkout");
const isFlat = computed(() => viewMode.value === "flat"); const priceLabel = computed(() => props.checkoutPrice.toFixed(2));
const handleSelectView = (mode: "flat" | "turntable") => {
viewMode.value = mode;
};
const handleExport = () => emit("export");
const handleDownloadPreview = () => emit("download-preview");
const handleDownloadProduction = () => emit("download-production");
</script> </script>
<template> <template>
<section class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-4 shadow-lg shadow-slate-950/40"> <section class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6 shadow-lg shadow-slate-950/40">
<header class="flex items-center justify-between"> <div class="space-y-4">
<div>
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">
Output Preview
</h3>
<p class="mt-1 text-sm text-slate-300">
{{ templateLabel }} {{ productionPixels }}×{{ productionPixels }} px
</p>
</div>
<button <button
type="button" type="button"
class="rounded-lg border border-sky-500/60 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-sky-100 transition hover:bg-sky-500/10 disabled:cursor-not-allowed disabled:border-slate-600 disabled:text-slate-500" class="w-full rounded-xl bg-emerald-500 px-6 py-4 text-base font-semibold text-emerald-950 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:bg-emerald-500/60 disabled:text-emerald-900/60"
:disabled="props.isExporting" :disabled="props.isCheckoutPending"
@click="handleExport" @click="handleCheckout"
> >
{{ props.isExporting ? "Exporting…" : "Generate Files" }} {{ props.isCheckoutPending ? "Redirecting…" : `Buy This Design ($${priceLabel})` }}
</button> </button>
</header>
<div class="mt-4 flex gap-2">
<button
type="button"
class="flex-1 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-wide transition"
:class="isFlat ? 'border-sky-500 bg-sky-500/10 text-sky-200' : 'border-slate-800 bg-slate-900 text-slate-400 hover:border-slate-700/80 hover:text-slate-200'"
@click="handleSelectView('flat')"
>
Flat View
</button>
<button
type="button"
class="flex-1 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-wide transition"
:class="!isFlat ? 'border-sky-500 bg-sky-500/10 text-sky-200' : 'border-slate-800 bg-slate-900 text-slate-400 hover:border-slate-700/80 hover:text-slate-200'"
@click="handleSelectView('turntable')"
>
Turntable View
</button>
</div>
<div class="mt-4 aspect-square overflow-hidden rounded-2xl border border-slate-800 bg-slate-950">
<template v-if="props.previewUrl">
<div v-if="isFlat" class="h-full w-full">
<img
:src="props.previewUrl"
alt="Slipmat preview flat"
class="h-full w-full object-cover"
/>
</div>
<div v-else class="relative h-full w-full bg-linear-to-br from-slate-900 via-slate-950 to-black">
<img
src="/turntable-mockup.svg"
alt="Turntable illustration"
class="pointer-events-none h-full w-full object-contain"
/>
<div class="absolute left-[16%] top-[18%] h-[64%] w-[48%] -rotate-2 overflow-hidden rounded-full shadow-xl shadow-black/40">
<div class="absolute inset-0 bg-slate-900/40" />
<img
:src="props.previewUrl"
alt="Slipmat preview turntable"
class="h-full w-full object-cover opacity-95 mix-blend-screen"
/>
</div>
</div>
</template>
<div <div
v-else v-if="props.checkoutError"
class="flex h-full items-center justify-center text-sm text-slate-500" class="rounded-xl border border-rose-500/60 bg-rose-500/10 px-4 py-3 text-sm text-rose-200"
> >
No preview yetstart designing! {{ props.checkoutError }}
</div> </div>
</div> </div>
<div class="mt-4 flex flex-wrap gap-3">
<button
type="button"
class="flex-1 rounded-xl bg-slate-800 px-4 py-3 text-sm font-medium text-slate-100 transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-800/70 disabled:text-slate-500"
:disabled="!props.previewUrl"
@click="handleDownloadPreview"
>
Download Web Preview
</button>
<button
type="button"
class="flex-1 rounded-xl bg-sky-600 px-4 py-3 text-sm font-medium text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:bg-slate-600/70"
:disabled="props.isExporting"
@click="handleDownloadProduction"
>
Download Print-Ready PNG
</button>
</div>
</section> </section>
</template> </template>

View File

@@ -9,8 +9,10 @@ const props = defineProps<{
onImportImage: (file: File) => Promise<void>; onImportImage: (file: File) => Promise<void>;
onFillChange: (fill: string) => void; onFillChange: (fill: string) => void;
onStrokeChange: (stroke: string) => void; onStrokeChange: (stroke: string) => void;
onBackgroundChange: (background: string) => void;
activeFill: string | null; activeFill: string | null;
activeStroke: string | null; activeStroke: string | null;
activeBackground: string;
canStyleSelection: boolean; canStyleSelection: boolean;
zoom: number; zoom: number;
minZoom: number; minZoom: number;
@@ -28,6 +30,7 @@ const emit = defineEmits<{
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const fillValue = ref(props.activeFill ?? "#111827"); const fillValue = ref(props.activeFill ?? "#111827");
const strokeValue = ref(props.activeStroke ?? "#3b82f6"); const strokeValue = ref(props.activeStroke ?? "#3b82f6");
const backgroundValue = ref(props.activeBackground ?? "#ffffff");
const zoomSliderValue = ref(Math.round(props.zoom * 100)); const zoomSliderValue = ref(Math.round(props.zoom * 100));
watch( watch(
@@ -44,6 +47,13 @@ watch(
} }
); );
watch(
() => props.activeBackground,
(next) => {
backgroundValue.value = next ?? "#ffffff";
}
);
watch( watch(
() => props.zoom, () => props.zoom,
(next) => { (next) => {
@@ -88,6 +98,13 @@ const handleStrokeChange = (event: Event) => {
props.onStrokeChange(value); props.onStrokeChange(value);
}; };
const handleBackgroundChange = (event: Event) => {
const input = event.target as HTMLInputElement;
const value = input.value;
backgroundValue.value = value;
props.onBackgroundChange(value);
};
const handleZoomInput = (event: Event) => { const handleZoomInput = (event: Event) => {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const value = Number(input.value); const value = Number(input.value);
@@ -102,53 +119,117 @@ const zoomLabel = computed(() => `${Math.round(props.zoom * 100)}%`);
</script> </script>
<template> <template>
<section class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-4 shadow-lg shadow-slate-950/30"> <div class="flex flex-wrap items-center gap-2 border-b border-slate-800/60 bg-slate-900/50 px-4 py-3">
<div class="flex items-center justify-between"> <!-- Add Elements Group -->
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400"> <div class="flex items-center gap-1 border-r border-slate-700/50 pr-3">
Canvas Tools
</h3>
<button <button
type="button" type="button"
class="rounded-lg border border-red-500/40 px-3 py-1 text-xs font-medium text-red-300 transition hover:bg-red-500/10" class="flex h-9 w-9 items-center justify-center rounded-lg text-lg font-bold text-slate-300 transition hover:bg-slate-800"
title="Add Text"
@click="props.onAddText"
>
T
</button>
<button
type="button"
class="flex h-9 w-9 items-center justify-center rounded-lg text-xl font-bold text-slate-300 transition hover:bg-slate-800"
title="Add Circle"
@click="props.onAddCircle"
>
</button>
<button
type="button"
class="flex h-9 w-9 items-center justify-center rounded-lg text-xl font-bold text-slate-300 transition hover:bg-slate-800"
title="Add Rectangle"
@click="props.onAddRectangle"
>
</button>
<button
type="button"
class="flex h-9 w-9 items-center justify-center rounded-lg text-xl font-bold text-slate-300 transition hover:bg-slate-800"
title="Upload Image"
@click="openFilePicker"
>
🖼
</button>
</div>
<!-- Color Pickers Group -->
<div class="flex items-center gap-2 border-r border-slate-700/50 pr-3">
<label class="flex items-center gap-1.5" title="Canvas Background">
<span class="text-xs text-slate-400">Canvas</span>
<input
type="color"
class="h-8 w-12 cursor-pointer rounded border border-slate-700/70 bg-slate-800/80 p-0.5"
:value="backgroundValue"
@input="handleBackgroundChange"
/>
</label>
<label class="flex items-center gap-1.5" title="Fill Color">
<span class="text-xs text-slate-400">Fill</span>
<input
type="color"
class="h-8 w-12 cursor-pointer rounded border border-slate-700/70 bg-slate-800/80 p-0.5"
:disabled="stylingDisabled"
:value="fillValue"
@input="handleFillChange"
/>
</label>
<label class="flex items-center gap-1.5" title="Stroke Color">
<span class="text-xs text-slate-400">Stroke</span>
<input
type="color"
class="h-8 w-12 cursor-pointer rounded border border-slate-700/70 bg-slate-800/80 p-0.5"
:disabled="stylingDisabled"
:value="strokeValue"
@input="handleStrokeChange"
/>
</label>
</div>
<!-- Zoom Controls -->
<div class="flex items-center gap-2 border-r border-slate-700/50 pr-3">
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded border border-slate-700/60 bg-slate-800/80 text-sm font-bold text-slate-100 transition hover:bg-slate-700"
title="Zoom Out"
@click="props.onZoomOut"
>
</button>
<span class="min-w-12 text-center text-xs font-medium text-slate-300">
{{ zoomLabel }}
</span>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded border border-slate-700/60 bg-slate-800/80 text-sm font-bold text-slate-100 transition hover:bg-slate-700"
title="Zoom In"
@click="props.onZoomIn"
>
+
</button>
<button
type="button"
class="rounded border border-slate-700/60 bg-slate-800/80 px-2 py-1 text-xs font-medium text-slate-200 transition hover:bg-slate-700"
title="Reset Zoom"
@click="props.onZoomReset"
>
Reset
</button>
</div>
<!-- Clear Button -->
<button
type="button"
class="ml-auto rounded border border-red-500/40 px-3 py-1.5 text-xs font-medium text-red-300 transition hover:bg-red-500/10"
@click="props.onClear" @click="props.onClear"
> >
Clear Canvas Clear Canvas
</button> </button>
</div>
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4"> <!-- Hidden File Input -->
<button
type="button"
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
@click="props.onAddText"
>
<span class="mb-2 text-3xl font-semibold">T</span>
<span class="text-xs uppercase tracking-wide">Add Text</span>
</button>
<button
type="button"
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
@click="props.onAddCircle"
>
<span class="mb-2 text-3xl font-semibold"></span>
<span class="text-xs uppercase tracking-wide">Add Circle</span>
</button>
<button
type="button"
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
@click="props.onAddRectangle"
>
<span class="mb-2 text-3xl font-semibold"></span>
<span class="text-xs uppercase tracking-wide">Add Rectangle</span>
</button>
<button
type="button"
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
@click="openFilePicker"
>
<span class="mb-2 text-3xl font-semibold"></span>
<span class="text-xs uppercase tracking-wide">Upload Image</span>
</button>
</div>
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
@@ -156,83 +237,6 @@ const zoomLabel = computed(() => `${Math.round(props.zoom * 100)}%`);
class="hidden" class="hidden"
@change="handleFileChange" @change="handleFileChange"
/> />
<div class="mt-5 rounded-xl border border-slate-800/60 bg-slate-900/70 p-4">
<div class="flex items-center justify-between gap-3">
<h4 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
Styling
</h4>
<span
v-if="stylingDisabled"
class="text-[11px] font-medium uppercase tracking-wide text-slate-500"
>
Select an object
</span>
</div> </div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
Fill
<input
type="color"
class="h-10 w-full cursor-pointer rounded-lg border border-slate-700/70 bg-slate-800/80 p-1"
:disabled="stylingDisabled"
:value="fillValue"
@input="handleFillChange"
/>
</label>
<label class="flex flex-col gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
Stroke
<input
type="color"
class="h-10 w-full cursor-pointer rounded-lg border border-slate-700/70 bg-slate-800/80 p-1"
:disabled="stylingDisabled"
:value="strokeValue"
@input="handleStrokeChange"
/>
</label>
</div>
</div>
<div class="mt-5 rounded-xl border border-slate-800/60 bg-slate-900/70 p-4">
<div class="flex items-center justify-between gap-3">
<h4 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
View
</h4>
<span class="text-[11px] font-medium uppercase tracking-wide text-slate-300">
{{ zoomLabel }}
</span>
</div>
<div class="mt-4 flex items-center gap-3">
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-700/60 bg-slate-800/80 text-lg font-bold text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10"
@click="props.onZoomOut"
>
</button>
<input
type="range"
class="flex-1 accent-sky-500"
:min="Math.round(props.minZoom * 100)"
:max="Math.round(props.maxZoom * 100)"
step="5"
:value="zoomSliderValue"
@input="handleZoomInput"
/>
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-700/60 bg-slate-800/80 text-lg font-bold text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10"
@click="props.onZoomIn"
>
+
</button>
</div>
<button
type="button"
class="mt-3 w-full rounded-lg border border-slate-700/60 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-200 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
@click="props.onZoomReset"
>
Reset Zoom
</button>
</div>
</section>
</template> </template>

View File

@@ -21,7 +21,7 @@ const handleSelect = (templateId: string) => {
<p class="mt-1 text-sm text-slate-400"> <p class="mt-1 text-sm text-slate-400">
Pick the vinyl size and print spec that matches this order. Pick the vinyl size and print spec that matches this order.
</p> </p>
<div class="mt-4 grid gap-3 md:grid-cols-2"> <div class="mt-4 grid gap-3 md:grid-cols-1">
<button <button
v-for="template in props.templates" v-for="template in props.templates"
:key="template.id" :key="template.id"
@@ -44,7 +44,7 @@ const handleSelect = (templateId: string) => {
Active Active
</span> </span>
</div> </div>
<dl class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-slate-300"> <dl class="mt-3 grid grid-cols-4 gap-x-3 gap-y-2 text-xs text-slate-300">
<div> <div>
<dt class="text-slate-500">Diameter</dt> <dt class="text-slate-500">Diameter</dt>
<dd>{{ template.diameterInches }}&quot;</dd> <dd>{{ template.diameterInches }}&quot;</dd>

290
app/composables/useAuth.ts Normal file
View File

@@ -0,0 +1,290 @@
import {
signInWithEmailAndPassword,
signInWithPopup,
GoogleAuthProvider,
signOut as firebaseSignOut,
onAuthStateChanged,
getAuth,
createUserWithEmailAndPassword,
} from "firebase/auth";
import { getApp, getApps, initializeApp } from "firebase/app";
import type { User } from "firebase/auth";
export const useAuth = () => {
const user = useState<User | null>("auth-user", () => null);
const isLoading = useState<boolean>("auth-loading", () => true);
const error = useState<string | null>("auth-error", () => null);
const firebaseReady = useState<boolean>("firebase-ready", () => false);
const listenerRegistered = useState<boolean>(
"auth-listener-registered",
() => false
);
const backendUser = useState<Record<string, any> | null>(
"auth-backend-user",
() => null
);
const config = useRuntimeConfig();
const ensureFirebaseApp = () => {
if (!process.client) {
return null;
}
if (!firebaseReady.value) {
const firebaseConfig = {
apiKey: config.public.firebaseApiKey,
authDomain: config.public.firebaseAuthDomain,
projectId: config.public.firebaseProjectId,
storageBucket: config.public.firebaseStorageBucket,
messagingSenderId: config.public.firebaseMessagingSenderId,
appId: config.public.firebaseAppId,
...(config.public.firebaseMeasurementId
? { measurementId: config.public.firebaseMeasurementId }
: {}),
};
if (getApps().length === 0) {
initializeApp(firebaseConfig);
}
firebaseReady.value = true;
}
try {
return getApp();
} catch (err) {
console.error("[useAuth] Failed to get Firebase app instance:", err);
return null;
}
};
const getAuthInstance = () => {
const app = ensureFirebaseApp();
return app ? getAuth(app) : null;
};
const authenticateWithBackend = async (idToken: string) => {
try {
const response = await $fetch<{
token?: string;
user?: Record<string, any> | null;
}>("/auth/login", {
baseURL: config.public.backendUrl,
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: {
idToken,
},
});
backendUser.value = response?.user ?? null;
return response;
} catch (err) {
console.error("Backend authentication failed:", err);
backendUser.value = null;
throw err;
}
};
const syncBackendWithToken = async (tokenProvider: () => Promise<string>) => {
try {
const idToken = await tokenProvider();
await authenticateWithBackend(idToken);
} catch (err) {
console.warn("[useAuth] Failed to sync backend session", err);
}
};
const registerListener = () => {
if (!process.client || listenerRegistered.value) {
return;
}
try {
const auth = getAuthInstance();
if (auth) {
const existingUser = auth.currentUser;
if (existingUser && !user.value) {
user.value = existingUser;
isLoading.value = false;
syncBackendWithToken(() => existingUser.getIdToken());
}
listenerRegistered.value = true;
onAuthStateChanged(auth, (firebaseUser) => {
user.value = firebaseUser;
isLoading.value = false;
if (firebaseUser) {
syncBackendWithToken(() => firebaseUser.getIdToken());
} else {
backendUser.value = null;
}
});
}
} catch (err) {
console.error("[useAuth] Failed to initialize auth listener:", err);
isLoading.value = false;
}
};
const initAuth = () => {
registerListener();
};
// Eagerly register listener when composable is used in client context
if (process.client) {
registerListener();
}
// Sign in with email and password
const signInWithEmail = async (email: string, password: string) => {
try {
error.value = null;
isLoading.value = true;
const auth = getAuthInstance();
if (!auth) {
throw new Error("Firebase not initialized");
}
const userCredential = await signInWithEmailAndPassword(
auth,
email,
password
);
const idToken = await userCredential.user.getIdToken();
try {
await authenticateWithBackend(idToken);
} catch (backendErr) {
console.warn(
"[useAuth] Backend authentication failed after email login:",
backendErr
);
}
user.value = userCredential.user;
registerListener();
return userCredential.user;
} catch (err: any) {
error.value = err.message;
throw err;
} finally {
isLoading.value = false;
}
};
// Sign in with Google
const signInWithGoogle = async () => {
try {
error.value = null;
isLoading.value = true;
const auth = getAuthInstance();
if (!auth) {
throw new Error("Firebase not initialized");
}
const provider = new GoogleAuthProvider();
const userCredential = await signInWithPopup(auth, provider);
const idToken = await userCredential.user.getIdToken();
try {
await authenticateWithBackend(idToken);
} catch (backendErr) {
console.warn(
"[useAuth] Backend authentication failed after Google login:",
backendErr
);
}
user.value = userCredential.user;
registerListener();
return userCredential.user;
} catch (err: any) {
error.value = err.message;
throw err;
} finally {
isLoading.value = false;
}
};
const registerWithEmail = async (email: string, password: string) => {
try {
error.value = null;
isLoading.value = true;
const auth = getAuthInstance();
if (!auth) {
throw new Error("Firebase not initialized");
}
const userCredential = await createUserWithEmailAndPassword(
auth,
email,
password
);
const idToken = await userCredential.user.getIdToken();
try {
await authenticateWithBackend(idToken);
} catch (backendErr) {
console.warn(
"[useAuth] Backend authentication failed after registration:",
backendErr
);
}
user.value = userCredential.user;
registerListener();
return userCredential.user;
} catch (err: any) {
error.value = err.message;
throw err;
} finally {
isLoading.value = false;
}
};
// Sign out
const signOut = async () => {
try {
const auth = getAuthInstance();
if (!auth) {
throw new Error("Firebase not initialized");
}
await firebaseSignOut(auth);
user.value = null;
backendUser.value = null;
} catch (err: any) {
error.value = err.message;
throw err;
}
};
// Get current user's ID token
const getIdToken = async () => {
if (!user.value) return null;
return await user.value.getIdToken();
};
return {
user: readonly(user),
isLoading: readonly(isLoading),
error: readonly(error),
backendUser: readonly(backendUser),
initAuth,
signInWithEmail,
signInWithGoogle,
registerWithEmail,
signOut,
getIdToken,
};
};

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1 @@
export const useLoginModal = () => useState<boolean>('login-modal-open', () => false);

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>

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

@@ -0,0 +1,431 @@
<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 }
),
]);
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);
};
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-slate-950 pb-16 text-slate-100">
<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-sky-400">
Slipmatz Designer
</p>
<h1 class="text-3xl font-bold text-white sm:text-4xl">
Craft custom slipmats ready for the pressing plant.
</h1>
</header>
<section class="mt-10 grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]">
<div class="space-y-6">
<TemplatePicker
:templates="templates"
:selected-template-id="selectedTemplate.id"
@select="handleTemplateSelect"
/>
<DesignerPreview
:is-checkout-pending="isCheckoutPending"
:checkout-price="DESIGN_PRICE_USD"
:checkout-error="checkoutError"
@checkout="handleCheckout"
/>
</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 shadow-2xl shadow-slate-950/60"
>
<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-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.
</p>
</div>
</div>
</div>
</section>
</div>
</main>
</template>

View File

@@ -1,124 +1,130 @@
<script setup lang="ts"> <script setup lang="ts">
import DesignerCanvas from "~/components/designer/DesignerCanvas.vue"; const router = useRouter();
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"; const startDesigning = () => {
router.push('/designer');
const {
templates,
selectedTemplate,
selectTemplate,
displaySize,
templateLabel,
productionPixelSize,
previewUrl,
registerCanvas,
unregisterCanvas,
addTextbox,
addShape,
addImageFromFile,
clearDesign,
downloadPreview,
downloadProduction,
exportDesign,
isExporting,
activeFillColor,
activeStrokeColor,
canStyleSelection,
setActiveFillColor,
setActiveStrokeColor,
zoomLevel,
minZoom,
maxZoom,
setZoom,
zoomIn,
zoomOut,
resetZoom,
} = useSlipmatDesigner();
const handleTemplateSelect = (templateId: string) => {
selectTemplate(templateId);
};
const handleExport = async () => {
await exportDesign();
}; };
</script> </script>
<template> <template>
<main class="min-h-screen bg-slate-950 pb-16 text-slate-100"> <div class="relative min-h-screen overflow-hidden bg-linear-to-br from-slate-950 via-slate-900 to-slate-950">
<div class="mx-auto max-w-6xl px-4 pt-12 sm:px-6 lg:px-8"> <!-- Background Pattern -->
<header class="space-y-3"> <div class="absolute inset-0 opacity-20">
<p class="text-sm uppercase tracking-[0.35em] text-sky-400"> <div class="absolute inset-0" style="background-image: radial-gradient(circle at 1px 1px, rgb(71 85 105) 1px, transparent 0); background-size: 40px 40px;"></div>
Slipmatz Designer </div>
</p>
<h1 class="text-3xl font-bold text-white sm:text-4xl"> <!-- Animated Slipmat -->
Craft custom slipmats ready for the pressing plant. <div class="absolute inset-0 flex items-center justify-center">
<div class="relative">
<!-- Turntable Base -->
<div class="absolute -inset-48 rounded-full bg-linear-to-br from-slate-800 to-slate-900 opacity-50 blur-3xl"></div>
<!-- Spinning Slipmat -->
<div class="relative h-[600px] w-[600px] sm:h-[700px] sm:w-[700px] md:h-[800px] md:w-[800px] animate-spin-slow">
<!-- Vinyl Record Background -->
<div class="absolute inset-0 rounded-full bg-linear-to-br from-slate-900 via-slate-800 to-black shadow-2xl"></div>
<!-- Grooves -->
<div class="absolute inset-8 rounded-full border-2 border-slate-700/30"></div>
<div class="absolute inset-16 rounded-full border-2 border-slate-700/20"></div>
<div class="absolute inset-24 rounded-full border-2 border-slate-700/10"></div>
<!-- Center Label -->
<div class="absolute inset-0 flex items-center justify-center">
<div class="h-48 w-48 sm:h-56 sm:w-56 md:h-64 md:w-64 rounded-full bg-linear-to-br from-sky-500 to-blue-600 shadow-lg flex items-center justify-center">
<div class="h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24 rounded-full bg-slate-950 shadow-inner"></div>
</div>
</div>
<!-- Slipmat Design Elements -->
<div class="absolute inset-32 rounded-full border-4 border-sky-500/20"></div>
<!-- Decorative Dots -->
<div class="absolute left-1/2 top-16 h-3 w-3 -translate-x-1/2 rounded-full bg-sky-400"></div>
<div class="absolute bottom-16 left-1/2 h-3 w-3 -translate-x-1/2 rounded-full bg-sky-400"></div>
<div class="absolute left-16 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-sky-400"></div>
<div class="absolute right-16 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-sky-400"></div>
</div>
<!-- Tonearm -->
<div class="absolute -right-24 top-1/2 h-2 w-40 sm:w-48 origin-left -translate-y-1/2 rotate-12 bg-linear-to-r from-slate-700 to-slate-600 shadow-lg"></div>
<div class="absolute -right-28 top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-slate-600 shadow-lg"></div>
</div>
</div>
<!-- Content -->
<div class="relative z-20 flex min-h-screen flex-col items-center justify-between px-4 py-12 text-center">
<!-- Top Section - Title & Description -->
<div class="w-full space-y-4 pt-8">
<h1 class="text-6xl font-bold tracking-tight text-white drop-shadow-2xl sm:text-7xl md:text-8xl lg:text-9xl">
Slipmatz
</h1> </h1>
<p class="max-w-3xl text-base text-slate-300"> <p class="text-xl text-sky-400 drop-shadow-lg sm:text-2xl md:text-3xl">
Pick a template, drop in artwork, and well generate both a high-fidelity Design Custom Slipmats for Your Vinyl
preview and a print-ready PNG at exact specs. Everything stays within a
circular safe zone to ensure clean results on vinyl.
</p> </p>
</header> <div class="mx-auto max-w-2xl space-y-2 px-4 pt-2">
<p class="text-base text-slate-300 drop-shadow-lg sm:text-lg">
<section class="mt-10 grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]"> Create professional, print-ready slipmat designs in minutes.
<div class="space-y-6"> </p>
<TemplatePicker <p class="text-sm text-slate-400 drop-shadow-md sm:text-base">
:templates="templates" Perfect for DJs, record labels, and vinyl enthusiasts.
:selected-template-id="selectedTemplate.id"
@select="handleTemplateSelect"
/>
<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"
:active-fill="activeFillColor"
:active-stroke="activeStrokeColor"
: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"
/>
<DesignerPreview
:preview-url="previewUrl"
:template-label="templateLabel"
:production-pixels="productionPixelSize"
:is-exporting="isExporting"
@export="handleExport"
@download-preview="downloadPreview"
@download-production="downloadProduction"
/>
</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">
<DesignerCanvas
:size="displaySize"
:background-color="selectedTemplate.backgroundColor"
:register-canvas="registerCanvas"
:unregister-canvas="unregisterCanvas"
/>
<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.
</p> </p>
</div> </div>
</div> </div>
</section>
<!-- Middle Section - Spacer (Turntable is in background) -->
<div class="flex-1"></div>
<!-- Bottom Section - Button & Stats -->
<div class="w-full space-y-12 pb-8">
<div>
<NuxtLink
to="/designer"
class="group relative inline-flex overflow-hidden rounded-full bg-linear-to-r from-sky-500 to-blue-600 px-12 py-5 text-xl font-bold text-white shadow-2xl transition-all hover:scale-105 hover:shadow-sky-500/50 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>
<div class="absolute inset-0 bg-linear-to-r from-blue-600 to-sky-500 opacity-0 transition-opacity group-hover:opacity-100"></div>
</NuxtLink>
</div>
<div class="mx-auto grid max-w-4xl gap-8 px-4 sm:grid-cols-3">
<div class="space-y-2 backdrop-blur-sm">
<div class="text-3xl font-bold text-sky-400 drop-shadow-lg md:text-4xl">12"</div>
<div class="text-sm text-slate-400 drop-shadow-md">Standard Size</div>
</div>
<div class="space-y-2 backdrop-blur-sm">
<div class="text-3xl font-bold text-sky-400 drop-shadow-lg md:text-4xl">300 DPI</div>
<div class="text-sm text-slate-400 drop-shadow-md">Print Quality</div>
</div>
<div class="space-y-2 backdrop-blur-sm">
<div class="text-3xl font-bold text-sky-400 drop-shadow-lg md:text-4xl">$39.99</div>
<div class="text-sm text-slate-400 drop-shadow-md">Per Design</div>
</div>
</div>
</div>
</div>
<!-- Bottom Wave -->
<div class="absolute bottom-0 left-0 right-0 h-32 bg-linear-to-t from-slate-950 to-transparent"></div>
</div> </div>
</main>
</template> </template>
<style scoped>
@keyframes spin-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: spin-slow 8s linear infinite;
}
</style>

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>

View File

@@ -13,15 +13,6 @@ export interface SlipmatTemplate {
backgroundColor: string; backgroundColor: string;
} }
export interface ExportedDesign {
previewUrl: string;
previewBlob: Blob;
productionUrl: string;
productionBlob: Blob;
templateId: string;
createdAt: string;
}
const DISPLAY_SIZE = 720; const DISPLAY_SIZE = 720;
const PREVIEW_SIZE = 1024; const PREVIEW_SIZE = 1024;
const MIN_ZOOM = 0.5; const MIN_ZOOM = 0.5;
@@ -59,6 +50,7 @@ const TEMPLATE_PRESETS: SlipmatTemplate[] = [
]; ];
type FabricCanvas = FabricNamespace.Canvas; type FabricCanvas = FabricNamespace.Canvas;
type FabricSerializedCanvas = ReturnType<FabricCanvas["toJSON"]>;
type FabricCircle = FabricNamespace.Circle; type FabricCircle = FabricNamespace.Circle;
type FabricRect = FabricNamespace.Rect; type FabricRect = FabricNamespace.Rect;
type FabricTextbox = FabricNamespace.Textbox; type FabricTextbox = FabricNamespace.Textbox;
@@ -69,6 +61,16 @@ type CanvasReadyPayload = {
fabric: FabricModule; fabric: FabricModule;
}; };
export interface ExportedDesign {
previewUrl: string;
previewBlob: Blob;
productionUrl: string;
productionBlob: Blob;
templateId: string;
createdAt: string;
canvasJson: FabricSerializedCanvas;
}
const FALLBACK_TEMPLATE: SlipmatTemplate = TEMPLATE_PRESETS[0] ?? { const FALLBACK_TEMPLATE: SlipmatTemplate = TEMPLATE_PRESETS[0] ?? {
id: "custom", id: "custom",
name: "Custom", name: "Custom",
@@ -320,8 +322,6 @@ export const useSlipmatDesigner = () => {
instance.on(eventName, handleMutation); instance.on(eventName, handleMutation);
}); });
instance.on("after:render", () => schedulePreviewRefresh());
const selectionEvents = [ const selectionEvents = [
"selection:created", "selection:created",
"selection:updated", "selection:updated",
@@ -605,6 +605,77 @@ export const useSlipmatDesigner = () => {
} }
}; };
const waitForCanvasReady = async (): Promise<FabricCanvas> => {
if (canvas.value) {
return canvas.value;
}
await new Promise<void>((resolve) => {
const stop = watch(
canvas,
(value) => {
if (value) {
stop();
resolve();
}
},
{ immediate: false }
);
});
if (!canvas.value) {
throw new Error("Canvas not ready");
}
return canvas.value;
};
const loadDesign = async (payload: {
templateId?: string | null;
canvasJson: string | FabricSerializedCanvas;
previewUrl?: string | null;
}) => {
if (payload.templateId) {
selectTemplate(payload.templateId);
}
const currentCanvas = await waitForCanvasReady();
if (!fabricApi.value) {
throw new Error("Fabric API not ready");
}
const parsedJson =
typeof payload.canvasJson === "string"
? (JSON.parse(payload.canvasJson) as FabricSerializedCanvas)
: payload.canvasJson;
await new Promise<void>((resolve, reject) => {
currentCanvas.loadFromJSON(parsedJson, () => {
maintainStaticLayerOrder();
updateSelectedStyleState();
currentCanvas.renderAll();
schedulePreviewRefresh();
resolve();
});
}).catch((error) => {
throw error;
});
// Reset cached assets; caller can provide preview if available.
previewBlob.value = null;
productionBlob.value = null;
if (productionObjectUrl.value) {
URL.revokeObjectURL(productionObjectUrl.value);
productionObjectUrl.value = null;
}
if (payload.previewUrl) {
previewUrl.value = payload.previewUrl;
} else {
await refreshPreview();
}
};
const exportDesign = async (): Promise<ExportedDesign | null> => { const exportDesign = async (): Promise<ExportedDesign | null> => {
const currentCanvas = canvas.value; const currentCanvas = canvas.value;
if (!currentCanvas) { if (!currentCanvas) {
@@ -629,6 +700,8 @@ export const useSlipmatDesigner = () => {
}); });
const productionDataBlob = await dataUrlToBlob(productionDataUrl); const productionDataBlob = await dataUrlToBlob(productionDataUrl);
const canvasJson = currentCanvas.toJSON();
previewUrl.value = previewDataUrl; previewUrl.value = previewDataUrl;
previewBlob.value = previewDataBlob; previewBlob.value = previewDataBlob;
productionBlob.value = productionDataBlob; productionBlob.value = productionDataBlob;
@@ -645,6 +718,7 @@ export const useSlipmatDesigner = () => {
productionBlob: productionDataBlob, productionBlob: productionDataBlob,
templateId: selectedTemplate.value.id, templateId: selectedTemplate.value.id,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
canvasJson,
}; };
} finally { } finally {
isExporting.value = false; isExporting.value = false;
@@ -677,6 +751,20 @@ export const useSlipmatDesigner = () => {
); );
}; };
const setBackgroundColor = (color: string) => {
const bgCircle = backgroundCircle.value;
if (!bgCircle || !canvas.value) {
return;
}
bgCircle.set({ fill: color });
selectedTemplate.value = {
...selectedTemplate.value,
backgroundColor: color,
};
canvas.value.requestRenderAll();
schedulePreviewRefresh();
};
watch(selectedTemplate, () => { watch(selectedTemplate, () => {
resetZoom(); resetZoom();
applyTemplateToCanvas(); applyTemplateToCanvas();
@@ -689,6 +777,7 @@ export const useSlipmatDesigner = () => {
templates, templates,
selectedTemplate, selectedTemplate,
selectTemplate, selectTemplate,
loadDesign,
displaySize, displaySize,
productionPixelSize, productionPixelSize,
templateLabel, templateLabel,
@@ -711,6 +800,7 @@ export const useSlipmatDesigner = () => {
addImageFromFile, addImageFromFile,
setActiveFillColor, setActiveFillColor,
setActiveStrokeColor, setActiveStrokeColor,
setBackgroundColor,
setZoom, setZoom,
zoomIn, zoomIn,
zoomOut, zoomOut,

34
docker-compose.yml Normal file
View File

@@ -0,0 +1,34 @@
services:
slipmatz-web:
build:
context: .
dockerfile: Dockerfile
container_name: slipmatz-web
ports:
- "3000:3000"
environment:
- NODE_ENV=production
# Add your environment variables here
- NUXT_PUBLIC_FIREBASE_API_KEY=${NUXT_PUBLIC_FIREBASE_API_KEY}
- NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN}
- NUXT_PUBLIC_FIREBASE_PROJECT_ID=${NUXT_PUBLIC_FIREBASE_PROJECT_ID}
- NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET}
- NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID}
- NUXT_PUBLIC_FIREBASE_APP_ID=${NUXT_PUBLIC_FIREBASE_APP_ID}
- NUXT_PUBLIC_STORAGE_URL=${NUXT_PUBLIC_STORAGE_URL}
- NUXT_PUBLIC_BACKEND_URL=${NUXT_PUBLIC_BACKEND_URL}
- NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
restart: unless-stopped
networks:
- slipmatz-network
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
slipmatz-network:
driver: bridge

View File

@@ -5,7 +5,29 @@ export default defineNuxtConfig({
compatibilityDate: "2025-07-15", compatibilityDate: "2025-07-15",
devtools: { enabled: true }, devtools: { enabled: true },
css: ["./app/assets/css/main.css"], css: ["./app/assets/css/main.css"],
modules: ["@nuxtjs/color-mode"],
vite: { vite: {
plugins: [tailwindcss()], plugins: [tailwindcss()],
}, },
runtimeConfig: {
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
public: {
firebaseApiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY,
firebaseAuthDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
firebaseProjectId: process.env.NUXT_PUBLIC_FIREBASE_PROJECT_ID,
firebaseStorageBucket: process.env.NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
firebaseMessagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
firebaseAppId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID,
firebaseMeasurementId: process.env.NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
stripePublishableKey: process.env.NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
backendUrl: process.env.NUXT_PUBLIC_BACKEND_URL || 'http://localhost:3000',
storageUrl: process.env.NUXT_PUBLIC_STORAGE_URL || 'http://localhost:9000',
}
},
colorMode: {
preference: 'light',
fallback: 'light',
classSuffix: ''
}
}); });

1078
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,12 @@
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {
"@nuxtjs/color-mode": "^3.5.2",
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"fabric": "^6.0.2", "fabric": "^6.0.2",
"firebase": "^12.5.0",
"nuxt": "^4.2.0", "nuxt": "^4.2.0",
"stripe": "^19.3.0",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"

View File

@@ -0,0 +1,74 @@
import Stripe from "stripe";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const stripeSecretKey = config.stripeSecretKey;
if (!stripeSecretKey) {
throw createError({
statusCode: 500,
statusMessage: "Stripe secret key not configured",
});
}
const stripe = new Stripe(stripeSecretKey, {
apiVersion: "2025-10-29.clover",
});
const body = await readBody<{
designId: string;
templateId?: string;
designName?: string;
amount: number;
currency?: string;
successUrl: string;
cancelUrl: string;
customerEmail?: string;
}>(event);
if (
!body?.designId ||
!body?.amount ||
!body?.successUrl ||
!body?.cancelUrl
) {
throw createError({
statusCode: 400,
statusMessage: "Missing required fields",
});
}
const { currency = "usd" } = body;
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
billing_address_collection: "auto",
customer_email: body.customerEmail,
shipping_address_collection: { allowed_countries: ["US", "CA"] },
line_items: [
{
quantity: 1,
price_data: {
currency,
unit_amount: Math.round(body.amount * 100),
product_data: {
name: body.designName ?? `Slipmat design ${body.designId}`,
metadata: {
designId: body.designId,
...(body.templateId ? { templateId: body.templateId } : {}),
},
},
},
},
],
metadata: {
designId: body.designId,
...(body.templateId ? { templateId: body.templateId } : {}),
},
success_url: body.successUrl,
cancel_url: body.cancelUrl,
});
return { id: session.id, url: session.url };
});

View File

@@ -0,0 +1,62 @@
import Stripe from 'stripe'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const stripeSecretKey = config.stripeSecretKey
if (!stripeSecretKey) {
throw createError({ statusCode: 500, statusMessage: 'Stripe secret key not configured' })
}
const params = event.context.params as { sessionId?: string }
const sessionId = params?.sessionId
if (!sessionId) {
throw createError({ statusCode: 400, statusMessage: 'Missing session id' })
}
const stripe = new Stripe(stripeSecretKey, {
apiVersion: '2025-10-29.clover',
})
try {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['payment_intent', 'customer'],
})
const customerDetails = session.customer_details ?? null
return {
id: session.id,
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
customerEmail: customerDetails?.email ?? session.customer_email ?? null,
customerName: customerDetails?.name ?? null,
createdAt: session.created ? new Date(session.created * 1000).toISOString() : null,
metadata: session.metadata ?? {},
customerDetails: customerDetails
? {
name: customerDetails.name ?? null,
email: customerDetails.email ?? null,
phone: customerDetails.phone ?? null,
address: customerDetails.address
? {
line1: customerDetails.address.line1 ?? null,
line2: customerDetails.address.line2 ?? null,
city: customerDetails.address.city ?? null,
state: customerDetails.address.state ?? null,
postalCode: customerDetails.address.postal_code ?? null,
country: customerDetails.address.country ?? null,
}
: null,
}
: null,
}
} catch (error: any) {
throw createError({
statusCode: error?.statusCode ?? 500,
statusMessage: error?.message ?? 'Unable to retrieve checkout session',
})
}
})

View File

@@ -0,0 +1,60 @@
export default defineEventHandler(async (event) => {
const body = await readBody<{
designId: string;
templateId: string;
ownerEmail?: string | null;
ownerId?: string | null;
previewUrl?: string | null;
productionUrl?: string | null;
canvasJson: unknown;
metadata?: Record<string, unknown>;
}>(event);
if (!body?.designId || !body?.templateId || body.canvasJson === undefined) {
throw createError({
statusCode: 400,
statusMessage: "Missing required design fields",
});
}
const config = useRuntimeConfig();
const backendUrl = config.public?.backendUrl;
if (!backendUrl) {
throw createError({
statusCode: 500,
statusMessage: "Backend URL not configured",
});
}
const payload = {
designId: body.designId,
templateId: body.templateId,
ownerEmail: body.ownerEmail ?? null,
ownerId: body.ownerId ?? null,
previewUrl: body.previewUrl ?? null,
productionUrl: body.productionUrl ?? null,
canvasJson: body.canvasJson,
metadata: body.metadata ?? {},
updatedAt: new Date().toISOString(),
};
try {
const result = await $fetch("/designs", {
baseURL: backendUrl,
method: "POST",
body: payload,
});
return {
ok: true,
result,
};
} catch (err) {
console.error("[designs] Failed to forward design payload", err);
throw createError({
statusCode: 502,
statusMessage: (err as Error)?.message ?? "Failed to persist design",
});
}
});

View File

@@ -0,0 +1,30 @@
export default defineEventHandler(async (event) => {
const params = event.context.params as { designId?: string }
const designId = params?.designId
if (!designId) {
throw createError({ statusCode: 400, statusMessage: "Missing design id" })
}
const config = useRuntimeConfig()
const backendUrl = config.public?.backendUrl
if (!backendUrl) {
throw createError({ statusCode: 500, statusMessage: "Backend URL not configured" })
}
try {
const design = await $fetch(`/designs/${encodeURIComponent(designId)}`, {
baseURL: backendUrl,
method: "GET",
})
return design
} catch (err) {
console.error(`[designs] Failed to fetch design ${designId}`, err)
throw createError({
statusCode: 502,
statusMessage: (err as Error)?.message ?? "Failed to load design",
})
}
})

43
server/api/orders.get.ts Normal file
View File

@@ -0,0 +1,43 @@
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const backendUrl = config.public?.backendUrl;
if (!backendUrl) {
throw createError({ statusCode: 500, statusMessage: "Backend URL not configured" });
}
const query = getQuery(event);
const customerEmail = typeof query.customerEmail === "string" ? query.customerEmail : null;
if (!customerEmail) {
throw createError({ statusCode: 400, statusMessage: "Missing customerEmail" });
}
try {
const result = await $fetch("/transactions", {
baseURL: backendUrl,
method: "GET",
query: { customerEmail },
});
if (Array.isArray(result)) {
return result;
}
if (result && Array.isArray((result as any).data)) {
return (result as any).data;
}
if (result && Array.isArray((result as any).orders)) {
return (result as any).orders;
}
return result;
} catch (err) {
console.error("[orders] Failed to fetch order history", err);
throw createError({
statusCode: 502,
statusMessage: (err as Error)?.message ?? "Failed to load order history",
});
}
});

View File

@@ -0,0 +1,73 @@
export default defineEventHandler(async (event) => {
const body = await readBody<{
stripeSessionId: string;
designId: string;
templateId?: string;
amount: number | string;
currency: string;
customerEmail?: string;
customerDetails?: {
name?: string | null;
email?: string | null;
phone?: string | null;
address?: {
line1?: string | null;
line2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
} | null;
};
}>(event);
if (!body?.stripeSessionId || !body?.designId) {
throw createError({
statusCode: 400,
statusMessage: "Missing required fields",
});
}
const config = useRuntimeConfig();
const backendUrl = config.public?.backendUrl;
if (!backendUrl) {
throw createError({
statusCode: 500,
statusMessage: "Backend URL not configured",
});
}
const record = {
stripeSessionId: body.stripeSessionId,
designId: body.designId,
templateId: body.templateId ?? null,
amount: body.amount.toString(),
currency: body.currency,
customerEmail: body.customerEmail ?? null,
customerDetails: body.customerDetails ?? null,
};
console.log("[transactions] Forwarding record to backend:", record);
try {
const backendResult = await $fetch("/transactions", {
baseURL: backendUrl,
method: "POST",
body: record,
});
return {
ok: true,
receivedAt: new Date().toISOString(),
backendResult,
};
} catch (err) {
console.error("[transactions] Failed to forward to backend", err);
throw createError({
statusCode: 502,
statusMessage:
(err as Error)?.message ?? "Failed to save transaction to backend",
});
}
});