Replace Firebase Storage with MinIO and add user account features

- Storage Integration:
  * Remove Firebase Storage dependency and useFirebaseStorage composable
  * Implement direct MinIO uploads via POST /storage/upload with multipart/form-data
  * Upload canvas JSON, preview PNG, and production PNG as separate objects
  * Store public URLs and metadata in design records

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

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

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

- API Updates:
  * Update transactions endpoint to accept amount as string
  * Format amount with .toFixed(2) in checkout success flow
  * Query orders by customerEmail instead of ownerId for consistency
This commit is contained in:
Frank John Begornia
2025-11-16 01:19:35 +08:00
parent 0ff41822af
commit bf701f8342
19 changed files with 1807 additions and 223 deletions

View File

@@ -3,6 +3,7 @@
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
@@ -15,3 +16,4 @@ STRIPE_WEBHOOK_SECRET=whsec_xxx
# Backend Configuration
NUXT_PUBLIC_BACKEND_URL=http://localhost:3000
NUXT_PUBLIC_STORAGE_URL=http://localhost:9000

View File

@@ -1,42 +1,137 @@
<script setup lang="ts">
const { user, signOut, initAuth, isLoading } = useAuth()
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
}
// Initialize auth on component mount
onMounted(() => {
initAuth()
})
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">
<p class="text-lg font-semibold text-white sm:text-xl">Slipmatz</p>
<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" class="flex items-center gap-3">
<span class="text-sm text-slate-300">{{ user.email }}</span>
<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"
class="rounded-full border border-slate-700/80 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-rose-500 hover:text-rose-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
role="menuitem"
>
Logout
</button>
</div>
</transition>
</div>
<!-- Show login button when not authenticated -->
<div v-else-if="!isLoading" class="flex items-center gap-3">

View File

@@ -99,6 +99,17 @@ const handleGoogleLogin = async () => {
{{ 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"

View File

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

View File

@@ -4,22 +4,34 @@ import {
GoogleAuthProvider,
signOut as firebaseSignOut,
onAuthStateChanged,
getAuth
} from 'firebase/auth'
import { getApps, initializeApp } from 'firebase/app'
import type { User } from 'firebase/auth'
getAuth,
createUserWithEmailAndPassword,
} from "firebase/auth";
import { getApp, getApps, initializeApp } from "firebase/app";
import type { User } from "firebase/auth";
export const useAuth = () => {
const user = ref<User | null>(null)
const isLoading = ref(true)
const error = ref<string | null>(null)
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 config = useRuntimeConfig();
// Initialize Firebase if not already initialized
const initializeFirebase = async () => {
if (process.client && getApps().length === 0) {
console.log('Initializing Firebase with config:')
const ensureFirebaseApp = () => {
if (!process.client) {
return null;
}
if (!firebaseReady.value) {
const firebaseConfig = {
apiKey: config.public.firebaseApiKey,
authDomain: config.public.firebaseAuthDomain,
@@ -29,149 +41,250 @@ export const useAuth = () => {
appId: config.public.firebaseAppId,
...(config.public.firebaseMeasurementId
? { measurementId: config.public.firebaseMeasurementId }
: {})
}
await initializeApp(firebaseConfig)
}
: {}),
};
if (getApps().length === 0) {
initializeApp(firebaseConfig);
}
// Get auth instance directly
const getAuthInstance = () => {
if (process.client) {
initializeFirebase()
return getAuth()
}
return null
firebaseReady.value = true;
}
// Initialize auth state listener
const initAuth = () => {
if (process.client) {
try {
const auth = getAuthInstance()
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
})
user.value = firebaseUser;
isLoading.value = false;
if (firebaseUser) {
syncBackendWithToken(() => firebaseUser.getIdToken());
} else {
backendUser.value = null;
}
});
}
} catch (err) {
console.error('Failed to initialize auth:', err)
isLoading.value = false
}
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
error.value = null;
isLoading.value = true;
const auth = getAuthInstance()
const auth = getAuthInstance();
if (!auth) {
throw new Error('Firebase not initialized')
throw new Error("Firebase not initialized");
}
const userCredential = await signInWithEmailAndPassword(auth, email, password)
const idToken = await userCredential.user.getIdToken()
const userCredential = await signInWithEmailAndPassword(
auth,
email,
password
);
const idToken = await userCredential.user.getIdToken();
try {
await authenticateWithBackend(idToken)
await authenticateWithBackend(idToken);
} catch (backendErr) {
console.warn('[useAuth] Backend authentication failed after email login:', backendErr)
console.warn(
"[useAuth] Backend authentication failed after email login:",
backendErr
);
}
return userCredential.user
user.value = userCredential.user;
registerListener();
return userCredential.user;
} catch (err: any) {
error.value = err.message
throw err
error.value = err.message;
throw err;
} finally {
isLoading.value = false
}
isLoading.value = false;
}
};
// Sign in with Google
const signInWithGoogle = async () => {
try {
error.value = null
isLoading.value = true
error.value = null;
isLoading.value = true;
const auth = getAuthInstance()
const auth = getAuthInstance();
if (!auth) {
throw new Error('Firebase not initialized')
throw new Error("Firebase not initialized");
}
const provider = new GoogleAuthProvider()
const userCredential = await signInWithPopup(auth, provider)
const idToken = await userCredential.user.getIdToken()
const provider = new GoogleAuthProvider();
const userCredential = await signInWithPopup(auth, provider);
const idToken = await userCredential.user.getIdToken();
try {
await authenticateWithBackend(idToken)
await authenticateWithBackend(idToken);
} catch (backendErr) {
console.warn('[useAuth] Backend authentication failed after Google login:', backendErr)
console.warn(
"[useAuth] Backend authentication failed after Google login:",
backendErr
);
}
return userCredential.user
user.value = userCredential.user;
registerListener();
return userCredential.user;
} catch (err: any) {
error.value = err.message
throw err
error.value = err.message;
throw err;
} finally {
isLoading.value = false
}
isLoading.value = false;
}
};
// Authenticate with backend
const authenticateWithBackend = async (idToken: string) => {
const registerWithEmail = async (email: string, password: string) => {
try {
const response = await $fetch('/auth/login', {
baseURL: config.public.backendUrl,
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: {
idToken
}
})
error.value = null;
isLoading.value = true;
return response
} catch (err) {
console.error('Backend authentication failed:', err)
throw err
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()
const auth = getAuthInstance();
if (!auth) {
throw new Error('Firebase not initialized')
throw new Error("Firebase not initialized");
}
await firebaseSignOut(auth)
user.value = null
await firebaseSignOut(auth);
user.value = null;
backendUser.value = null;
} catch (err: any) {
error.value = err.message
throw err
}
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()
}
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
}
}
getIdToken,
};
};

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

@@ -13,15 +13,6 @@ export interface SlipmatTemplate {
backgroundColor: string;
}
export interface ExportedDesign {
previewUrl: string;
previewBlob: Blob;
productionUrl: string;
productionBlob: Blob;
templateId: string;
createdAt: string;
}
const DISPLAY_SIZE = 720;
const PREVIEW_SIZE = 1024;
const MIN_ZOOM = 0.5;
@@ -59,6 +50,7 @@ const TEMPLATE_PRESETS: SlipmatTemplate[] = [
];
type FabricCanvas = FabricNamespace.Canvas;
type FabricSerializedCanvas = ReturnType<FabricCanvas["toJSON"]>;
type FabricCircle = FabricNamespace.Circle;
type FabricRect = FabricNamespace.Rect;
type FabricTextbox = FabricNamespace.Textbox;
@@ -69,6 +61,16 @@ type CanvasReadyPayload = {
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] ?? {
id: "custom",
name: "Custom",
@@ -605,6 +607,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 currentCanvas = canvas.value;
if (!currentCanvas) {
@@ -629,6 +702,8 @@ export const useSlipmatDesigner = () => {
});
const productionDataBlob = await dataUrlToBlob(productionDataUrl);
const canvasJson = currentCanvas.toJSON();
previewUrl.value = previewDataUrl;
previewBlob.value = previewDataBlob;
productionBlob.value = productionDataBlob;
@@ -645,6 +720,7 @@ export const useSlipmatDesigner = () => {
productionBlob: productionDataBlob,
templateId: selectedTemplate.value.id,
createdAt: new Date().toISOString(),
canvasJson,
};
} finally {
isExporting.value = false;
@@ -689,6 +765,7 @@ export const useSlipmatDesigner = () => {
templates,
selectedTemplate,
selectTemplate,
loadDesign,
displaySize,
productionPixelSize,
templateLabel,

View File

@@ -21,7 +21,8 @@ export default defineNuxtConfig({
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'
backendUrl: process.env.NUXT_PUBLIC_BACKEND_URL || 'http://localhost:3000',
storageUrl: process.env.NUXT_PUBLIC_STORAGE_URL || 'http://localhost:9000',
}
},
colorMode: {

View File

@@ -1,39 +1,51 @@
import Stripe from 'stripe'
import Stripe from "stripe";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const stripeSecretKey = config.stripeSecretKey
const config = useRuntimeConfig();
const stripeSecretKey = config.stripeSecretKey;
if (!stripeSecretKey) {
throw createError({ statusCode: 500, statusMessage: 'Stripe secret key not configured' })
throw createError({
statusCode: 500,
statusMessage: "Stripe secret key not configured",
});
}
const stripe = new Stripe(stripeSecretKey, {
apiVersion: '2024-10-28.acacia',
})
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)
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' })
if (
!body?.designId ||
!body?.amount ||
!body?.successUrl ||
!body?.cancelUrl
) {
throw createError({
statusCode: 400,
statusMessage: "Missing required fields",
});
}
const { currency = 'usd' } = body
const { currency = "usd" } = body;
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
billing_address_collection: 'auto',
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,
@@ -56,7 +68,7 @@ export default defineEventHandler(async (event) => {
},
success_url: body.successUrl,
cancel_url: body.cancelUrl,
})
});
return { id: session.id, url: session.url }
})
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

@@ -1,36 +1,73 @@
export default defineEventHandler(async (event) => {
const body = await readBody<{
stripeSessionId: string
designId: string
templateId?: string
amount: number
currency: string
customerEmail?: string
assets?: {
previewUrl?: string
productionUrl?: string
}
}>(event)
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' })
throw createError({
statusCode: 400,
statusMessage: "Missing required fields",
});
}
// TODO: Persist the transaction to your database of choice.
// Example shape:
// await db.transaction.create({
// stripeSessionId: body.stripeSessionId,
// designId: body.designId,
// templateId: body.templateId,
// amount: body.amount,
// currency: body.currency,
// customerEmail: body.customerEmail,
// previewUrl: body.assets?.previewUrl,
// productionUrl: body.assets?.productionUrl,
// })
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",
});
}
})
});