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:
@@ -3,6 +3,7 @@
|
|||||||
NUXT_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key_here
|
NUXT_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key_here
|
||||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_firebase_auth_domain_here
|
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_firebase_auth_domain_here
|
||||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_firebase_project_id_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_STORAGE_BUCKET=your_firebase_storage_bucket_here
|
||||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id_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_APP_ID=your_firebase_app_id_here
|
||||||
@@ -15,3 +16,4 @@ STRIPE_WEBHOOK_SECRET=whsec_xxx
|
|||||||
|
|
||||||
# Backend Configuration
|
# Backend Configuration
|
||||||
NUXT_PUBLIC_BACKEND_URL=http://localhost:3000
|
NUXT_PUBLIC_BACKEND_URL=http://localhost:3000
|
||||||
|
NUXT_PUBLIC_STORAGE_URL=http://localhost:9000
|
||||||
@@ -1,41 +1,136 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { user, signOut, initAuth, isLoading } = useAuth()
|
const { user, backendUser, signOut, initAuth, isLoading } = useAuth()
|
||||||
const loginModal = useLoginModal()
|
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 = () => {
|
const openLoginModal = () => {
|
||||||
loginModal.value = true
|
loginModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize auth on component mount
|
|
||||||
onMounted(() => {
|
|
||||||
initAuth()
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
try {
|
try {
|
||||||
await signOut()
|
await signOut()
|
||||||
|
showMenu.value = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sign out failed:', 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="sticky top-0 z-30 border-b border-slate-800/60 bg-slate-950/80 backdrop-blur">
|
<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="mx-auto max-w-6xl px-4 py-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center justify-between gap-6">
|
<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">
|
<div class="flex items-center gap-3">
|
||||||
<!-- Show user info and logout when authenticated -->
|
<!-- Show user info and logout when authenticated -->
|
||||||
<div v-if="user && !isLoading" class="flex items-center gap-3">
|
<div v-if="user && !isLoading" ref="dropdownRef" class="relative flex items-center gap-3">
|
||||||
<span class="text-sm text-slate-300">{{ user.email }}</span>
|
<span class="hidden text-sm text-slate-300 sm:inline">{{ backendUser?.email || user.email }}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="handleSignOut"
|
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"
|
||||||
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"
|
@click.stop="toggleMenu"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
:aria-expanded="showMenu"
|
||||||
>
|
>
|
||||||
Logout
|
{{ avatarInitials }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Show login button when not authenticated -->
|
<!-- Show login button when not authenticated -->
|
||||||
|
|||||||
@@ -99,6 +99,17 @@ const handleGoogleLogin = async () => {
|
|||||||
{{ loginError || error }}
|
{{ loginError || error }}
|
||||||
</div>
|
</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
|
<button
|
||||||
@click="isOpen = false"
|
@click="isOpen = false"
|
||||||
class="mt-4 text-sm text-slate-400 hover:text-white"
|
class="mt-4 text-sm text-slate-400 hover:text-white"
|
||||||
|
|||||||
@@ -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 }}"</dd>
|
<dd>{{ template.diameterInches }}"</dd>
|
||||||
|
|||||||
@@ -4,22 +4,34 @@ import {
|
|||||||
GoogleAuthProvider,
|
GoogleAuthProvider,
|
||||||
signOut as firebaseSignOut,
|
signOut as firebaseSignOut,
|
||||||
onAuthStateChanged,
|
onAuthStateChanged,
|
||||||
getAuth
|
getAuth,
|
||||||
} from 'firebase/auth'
|
createUserWithEmailAndPassword,
|
||||||
import { getApps, initializeApp } from 'firebase/app'
|
} from "firebase/auth";
|
||||||
import type { User } from 'firebase/auth'
|
import { getApp, getApps, initializeApp } from "firebase/app";
|
||||||
|
import type { User } from "firebase/auth";
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const user = ref<User | null>(null)
|
const user = useState<User | null>("auth-user", () => null);
|
||||||
const isLoading = ref(true)
|
const isLoading = useState<boolean>("auth-loading", () => true);
|
||||||
const error = ref<string | null>(null)
|
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 ensureFirebaseApp = () => {
|
||||||
const initializeFirebase = async () => {
|
if (!process.client) {
|
||||||
if (process.client && getApps().length === 0) {
|
return null;
|
||||||
console.log('Initializing Firebase with config:')
|
}
|
||||||
|
|
||||||
|
if (!firebaseReady.value) {
|
||||||
const firebaseConfig = {
|
const firebaseConfig = {
|
||||||
apiKey: config.public.firebaseApiKey,
|
apiKey: config.public.firebaseApiKey,
|
||||||
authDomain: config.public.firebaseAuthDomain,
|
authDomain: config.public.firebaseAuthDomain,
|
||||||
@@ -29,149 +41,250 @@ export const useAuth = () => {
|
|||||||
appId: config.public.firebaseAppId,
|
appId: config.public.firebaseAppId,
|
||||||
...(config.public.firebaseMeasurementId
|
...(config.public.firebaseMeasurementId
|
||||||
? { measurementId: config.public.firebaseMeasurementId }
|
? { measurementId: config.public.firebaseMeasurementId }
|
||||||
: {})
|
: {}),
|
||||||
}
|
};
|
||||||
await initializeApp(firebaseConfig)
|
|
||||||
}
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Get auth instance directly
|
|
||||||
const getAuthInstance = () => {
|
const getAuthInstance = () => {
|
||||||
if (process.client) {
|
const app = ensureFirebaseApp();
|
||||||
initializeFirebase()
|
return app ? getAuth(app) : null;
|
||||||
return getAuth()
|
};
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize auth state listener
|
const authenticateWithBackend = async (idToken: string) => {
|
||||||
const initAuth = () => {
|
try {
|
||||||
if (process.client) {
|
const response = await $fetch<{
|
||||||
try {
|
token?: string;
|
||||||
const auth = getAuthInstance()
|
user?: Record<string, any> | null;
|
||||||
if (auth) {
|
}>("/auth/login", {
|
||||||
onAuthStateChanged(auth, (firebaseUser) => {
|
baseURL: config.public.backendUrl,
|
||||||
user.value = firebaseUser
|
method: "POST",
|
||||||
isLoading.value = false
|
headers: {
|
||||||
})
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
} catch (err) {
|
body: {
|
||||||
console.error('Failed to initialize auth:', err)
|
idToken,
|
||||||
isLoading.value = false
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
|
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
|
// Sign in with email and password
|
||||||
const signInWithEmail = async (email: string, password: string) => {
|
const signInWithEmail = async (email: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
error.value = null
|
error.value = null;
|
||||||
isLoading.value = true
|
isLoading.value = true;
|
||||||
|
|
||||||
const auth = getAuthInstance()
|
const auth = getAuthInstance();
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
throw new Error('Firebase not initialized')
|
throw new Error("Firebase not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
const userCredential = await signInWithEmailAndPassword(auth, email, password)
|
const userCredential = await signInWithEmailAndPassword(
|
||||||
const idToken = await userCredential.user.getIdToken()
|
auth,
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
);
|
||||||
|
const idToken = await userCredential.user.getIdToken();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authenticateWithBackend(idToken)
|
await authenticateWithBackend(idToken);
|
||||||
} catch (backendErr) {
|
} 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) {
|
} catch (err: any) {
|
||||||
error.value = err.message
|
error.value = err.message;
|
||||||
throw err
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Sign in with Google
|
// Sign in with Google
|
||||||
const signInWithGoogle = async () => {
|
const signInWithGoogle = async () => {
|
||||||
try {
|
try {
|
||||||
error.value = null
|
error.value = null;
|
||||||
isLoading.value = true
|
isLoading.value = true;
|
||||||
|
|
||||||
const auth = getAuthInstance()
|
const auth = getAuthInstance();
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
throw new Error('Firebase not initialized')
|
throw new Error("Firebase not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = new GoogleAuthProvider()
|
const provider = new GoogleAuthProvider();
|
||||||
const userCredential = await signInWithPopup(auth, provider)
|
const userCredential = await signInWithPopup(auth, provider);
|
||||||
const idToken = await userCredential.user.getIdToken()
|
const idToken = await userCredential.user.getIdToken();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authenticateWithBackend(idToken)
|
await authenticateWithBackend(idToken);
|
||||||
} catch (backendErr) {
|
} 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) {
|
} catch (err: any) {
|
||||||
error.value = err.message
|
error.value = err.message;
|
||||||
throw err
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Authenticate with backend
|
const registerWithEmail = async (email: string, password: string) => {
|
||||||
const authenticateWithBackend = async (idToken: string) => {
|
|
||||||
try {
|
try {
|
||||||
const response = await $fetch('/auth/login', {
|
error.value = null;
|
||||||
baseURL: config.public.backendUrl,
|
isLoading.value = true;
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
idToken
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return response
|
const auth = getAuthInstance();
|
||||||
} catch (err) {
|
if (!auth) {
|
||||||
console.error('Backend authentication failed:', err)
|
throw new Error("Firebase not initialized");
|
||||||
throw err
|
}
|
||||||
|
|
||||||
|
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
|
// Sign out
|
||||||
const signOut = async () => {
|
const signOut = async () => {
|
||||||
try {
|
try {
|
||||||
const auth = getAuthInstance()
|
const auth = getAuthInstance();
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
throw new Error('Firebase not initialized')
|
throw new Error("Firebase not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
await firebaseSignOut(auth)
|
await firebaseSignOut(auth);
|
||||||
user.value = null
|
user.value = null;
|
||||||
|
backendUser.value = null;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.message
|
error.value = err.message;
|
||||||
throw err
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Get current user's ID token
|
// Get current user's ID token
|
||||||
const getIdToken = async () => {
|
const getIdToken = async () => {
|
||||||
if (!user.value) return null
|
if (!user.value) return null;
|
||||||
return await user.value.getIdToken()
|
return await user.value.getIdToken();
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: readonly(user),
|
user: readonly(user),
|
||||||
isLoading: readonly(isLoading),
|
isLoading: readonly(isLoading),
|
||||||
error: readonly(error),
|
error: readonly(error),
|
||||||
|
backendUser: readonly(backendUser),
|
||||||
initAuth,
|
initAuth,
|
||||||
signInWithEmail,
|
signInWithEmail,
|
||||||
signInWithGoogle,
|
signInWithGoogle,
|
||||||
|
registerWithEmail,
|
||||||
signOut,
|
signOut,
|
||||||
getIdToken
|
getIdToken,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
1
app/composables/useFirebaseStorage.ts
Normal file
1
app/composables/useFirebaseStorage.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
235
app/pages/checkout/success.vue
Normal file
235
app/pages/checkout/success.vue
Normal 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>
|
||||||
@@ -11,6 +11,7 @@ const {
|
|||||||
templates,
|
templates,
|
||||||
selectedTemplate,
|
selectedTemplate,
|
||||||
selectTemplate,
|
selectTemplate,
|
||||||
|
loadDesign,
|
||||||
displaySize,
|
displaySize,
|
||||||
templateLabel,
|
templateLabel,
|
||||||
productionPixelSize,
|
productionPixelSize,
|
||||||
@@ -41,13 +42,214 @@ const {
|
|||||||
|
|
||||||
const DESIGN_PRICE_USD = 39.99;
|
const DESIGN_PRICE_USD = 39.99;
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user, backendUser, initAuth, isLoading } = useAuth();
|
||||||
const loginModal = useLoginModal();
|
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 isCheckoutPending = ref(false);
|
||||||
const checkoutError = ref<string | null>(null);
|
const checkoutError = ref<string | null>(null);
|
||||||
const lastExportedDesign = ref<ExportedDesign | 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) => {
|
const handleTemplateSelect = (templateId: string) => {
|
||||||
selectTemplate(templateId);
|
selectTemplate(templateId);
|
||||||
};
|
};
|
||||||
@@ -64,6 +266,18 @@ const handleCheckout = async () => {
|
|||||||
return;
|
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) {
|
if (!user.value) {
|
||||||
loginModal.value = true;
|
loginModal.value = true;
|
||||||
return;
|
return;
|
||||||
@@ -83,26 +297,42 @@ const handleCheckout = async () => {
|
|||||||
lastExportedDesign.value = exportResult;
|
lastExportedDesign.value = exportResult;
|
||||||
|
|
||||||
const designId =
|
const designId =
|
||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
activeDesignId.value ??
|
||||||
|
(typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
? crypto.randomUUID()
|
? 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 successUrlTemplate = `${window.location.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
|
||||||
const cancelUrl = window.location.href;
|
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 }>(
|
||||||
method: "POST",
|
"/api/checkout.session",
|
||||||
body: {
|
{
|
||||||
designId,
|
method: "POST",
|
||||||
templateId: exportResult.templateId,
|
body: {
|
||||||
designName: templateLabel.value,
|
designId,
|
||||||
amount: DESIGN_PRICE_USD,
|
templateId: exportResult.templateId,
|
||||||
currency: "usd",
|
designName: templateLabel.value,
|
||||||
successUrl: successUrlTemplate,
|
amount: DESIGN_PRICE_USD,
|
||||||
cancelUrl,
|
currency: "usd",
|
||||||
customerEmail: user.value?.email,
|
successUrl: successUrlTemplate,
|
||||||
},
|
cancelUrl,
|
||||||
});
|
customerEmail: user.value?.email ?? undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!session?.id) {
|
if (!session?.id) {
|
||||||
throw new Error("Stripe session could not be created.");
|
throw new Error("Stripe session could not be created.");
|
||||||
@@ -113,11 +343,15 @@ const handleCheckout = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackRedirect = successUrlTemplate.replace("{CHECKOUT_SESSION_ID}", session.id);
|
const fallbackRedirect = successUrlTemplate.replace(
|
||||||
|
"{CHECKOUT_SESSION_ID}",
|
||||||
|
session.id
|
||||||
|
);
|
||||||
window.location.href = fallbackRedirect;
|
window.location.href = fallbackRedirect;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Checkout failed", err);
|
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 {
|
} finally {
|
||||||
isCheckoutPending.value = false;
|
isCheckoutPending.value = false;
|
||||||
}
|
}
|
||||||
@@ -169,7 +403,7 @@ const handleCheckout = async () => {
|
|||||||
:preview-url="previewUrl"
|
:preview-url="previewUrl"
|
||||||
:template-label="templateLabel"
|
:template-label="templateLabel"
|
||||||
:production-pixels="productionPixelSize"
|
:production-pixels="productionPixelSize"
|
||||||
:is-exporting="isExporting"
|
:is-exporting="isExporting || isDesignLoading"
|
||||||
:is-checkout-pending="isCheckoutPending"
|
:is-checkout-pending="isCheckoutPending"
|
||||||
:checkout-price="DESIGN_PRICE_USD"
|
:checkout-price="DESIGN_PRICE_USD"
|
||||||
:checkout-error="checkoutError"
|
:checkout-error="checkoutError"
|
||||||
@@ -181,7 +415,9 @@ const handleCheckout = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<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
|
<DesignerCanvas
|
||||||
:size="displaySize"
|
:size="displaySize"
|
||||||
:background-color="selectedTemplate.backgroundColor"
|
:background-color="selectedTemplate.backgroundColor"
|
||||||
@@ -190,8 +426,8 @@ const handleCheckout = async () => {
|
|||||||
/>
|
/>
|
||||||
<p class="mt-4 text-sm text-slate-400">
|
<p class="mt-4 text-sm text-slate-400">
|
||||||
Safe zone and bleed guides update automatically when you switch
|
Safe zone and bleed guides update automatically when you switch
|
||||||
templates. Use the toolbar to layer text, shapes, and imagery inside the
|
templates. Use the toolbar to layer text, shapes, and imagery
|
||||||
circular boundary.
|
inside the circular boundary.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
227
app/pages/orders.vue
Normal file
227
app/pages/orders.vue
Normal 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
156
app/pages/profile.vue
Normal 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
186
app/pages/register.vue
Normal 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>
|
||||||
@@ -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",
|
||||||
@@ -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 exportDesign = async (): Promise<ExportedDesign | null> => {
|
||||||
const currentCanvas = canvas.value;
|
const currentCanvas = canvas.value;
|
||||||
if (!currentCanvas) {
|
if (!currentCanvas) {
|
||||||
@@ -629,6 +702,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 +720,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;
|
||||||
@@ -689,6 +765,7 @@ export const useSlipmatDesigner = () => {
|
|||||||
templates,
|
templates,
|
||||||
selectedTemplate,
|
selectedTemplate,
|
||||||
selectTemplate,
|
selectTemplate,
|
||||||
|
loadDesign,
|
||||||
displaySize,
|
displaySize,
|
||||||
productionPixelSize,
|
productionPixelSize,
|
||||||
templateLabel,
|
templateLabel,
|
||||||
@@ -697,9 +774,9 @@ export const useSlipmatDesigner = () => {
|
|||||||
productionBlob,
|
productionBlob,
|
||||||
productionObjectUrl,
|
productionObjectUrl,
|
||||||
isExporting,
|
isExporting,
|
||||||
activeFillColor,
|
activeFillColor,
|
||||||
activeStrokeColor,
|
activeStrokeColor,
|
||||||
canStyleSelection,
|
canStyleSelection,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
zoomPercent,
|
zoomPercent,
|
||||||
minZoom: MIN_ZOOM,
|
minZoom: MIN_ZOOM,
|
||||||
@@ -709,8 +786,8 @@ export const useSlipmatDesigner = () => {
|
|||||||
addTextbox,
|
addTextbox,
|
||||||
addShape,
|
addShape,
|
||||||
addImageFromFile,
|
addImageFromFile,
|
||||||
setActiveFillColor,
|
setActiveFillColor,
|
||||||
setActiveStrokeColor,
|
setActiveStrokeColor,
|
||||||
setZoom,
|
setZoom,
|
||||||
zoomIn,
|
zoomIn,
|
||||||
zoomOut,
|
zoomOut,
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export default defineNuxtConfig({
|
|||||||
firebaseAppId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID,
|
firebaseAppId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID,
|
||||||
firebaseMeasurementId: process.env.NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
|
firebaseMeasurementId: process.env.NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
|
||||||
stripePublishableKey: process.env.NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
|
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: {
|
colorMode: {
|
||||||
|
|||||||
@@ -1,39 +1,51 @@
|
|||||||
import Stripe from 'stripe'
|
import Stripe from "stripe";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig();
|
||||||
const stripeSecretKey = config.stripeSecretKey
|
const stripeSecretKey = config.stripeSecretKey;
|
||||||
|
|
||||||
if (!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, {
|
const stripe = new Stripe(stripeSecretKey, {
|
||||||
apiVersion: '2024-10-28.acacia',
|
apiVersion: "2025-10-29.clover",
|
||||||
})
|
});
|
||||||
|
|
||||||
const body = await readBody<{
|
const body = await readBody<{
|
||||||
designId: string
|
designId: string;
|
||||||
templateId?: string
|
templateId?: string;
|
||||||
designName?: string
|
designName?: string;
|
||||||
amount: number
|
amount: number;
|
||||||
currency?: string
|
currency?: string;
|
||||||
successUrl: string
|
successUrl: string;
|
||||||
cancelUrl: string
|
cancelUrl: string;
|
||||||
customerEmail?: string
|
customerEmail?: string;
|
||||||
}>(event)
|
}>(event);
|
||||||
|
|
||||||
if (!body?.designId || !body?.amount || !body?.successUrl || !body?.cancelUrl) {
|
if (
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Missing required fields' })
|
!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({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: 'payment',
|
mode: "payment",
|
||||||
payment_method_types: ['card'],
|
payment_method_types: ["card"],
|
||||||
billing_address_collection: 'auto',
|
billing_address_collection: "auto",
|
||||||
customer_email: body.customerEmail,
|
customer_email: body.customerEmail,
|
||||||
|
shipping_address_collection: { allowed_countries: ["US", "CA"] },
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
@@ -56,7 +68,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
},
|
},
|
||||||
success_url: body.successUrl,
|
success_url: body.successUrl,
|
||||||
cancel_url: body.cancelUrl,
|
cancel_url: body.cancelUrl,
|
||||||
})
|
});
|
||||||
|
|
||||||
return { id: session.id, url: session.url }
|
return { id: session.id, url: session.url };
|
||||||
})
|
});
|
||||||
|
|||||||
62
server/api/checkout/[sessionId].get.ts
Normal file
62
server/api/checkout/[sessionId].get.ts
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
60
server/api/designs.post.ts
Normal file
60
server/api/designs.post.ts
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
30
server/api/designs/[designId].get.ts
Normal file
30
server/api/designs/[designId].get.ts
Normal 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
43
server/api/orders.get.ts
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,36 +1,73 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody<{
|
const body = await readBody<{
|
||||||
stripeSessionId: string
|
stripeSessionId: string;
|
||||||
designId: string
|
designId: string;
|
||||||
templateId?: string
|
templateId?: string;
|
||||||
amount: number
|
amount: number | string;
|
||||||
currency: string
|
currency: string;
|
||||||
customerEmail?: string
|
customerEmail?: string;
|
||||||
assets?: {
|
customerDetails?: {
|
||||||
previewUrl?: string
|
name?: string | null;
|
||||||
productionUrl?: string
|
email?: string | null;
|
||||||
}
|
phone?: string | null;
|
||||||
}>(event)
|
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) {
|
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.
|
const config = useRuntimeConfig();
|
||||||
// Example shape:
|
const backendUrl = config.public?.backendUrl;
|
||||||
// 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,
|
|
||||||
// })
|
|
||||||
|
|
||||||
return {
|
if (!backendUrl) {
|
||||||
ok: true,
|
throw createError({
|
||||||
receivedAt: new Date().toISOString(),
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user