Diameter
{{ template.diameterInches }}"
diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts
index 81f27a8..bea0128 100644
--- a/app/composables/useAuth.ts
+++ b/app/composables/useAuth.ts
@@ -1,25 +1,37 @@
-import {
- signInWithEmailAndPassword,
- signInWithPopup,
+import {
+ signInWithEmailAndPassword,
+ signInWithPopup,
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
(null)
- const isLoading = ref(true)
- const error = ref(null)
-
- const config = useRuntimeConfig()
+ const user = useState("auth-user", () => null);
+ const isLoading = useState("auth-loading", () => true);
+ const error = useState("auth-error", () => null);
+ const firebaseReady = useState("firebase-ready", () => false);
+ const listenerRegistered = useState(
+ "auth-listener-registered",
+ () => false
+ );
+ const backendUser = useState | null>(
+ "auth-backend-user",
+ () => null
+ );
- // Initialize Firebase if not already initialized
- const initializeFirebase = async () => {
- if (process.client && getApps().length === 0) {
- console.log('Initializing Firebase with config:')
+ const config = useRuntimeConfig();
+
+ 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);
+ }
+
+ 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 = () => {
- if (process.client) {
- initializeFirebase()
- return getAuth()
- }
- return null
- }
+ const app = ensureFirebaseApp();
+ return app ? getAuth(app) : null;
+ };
- // Initialize auth state listener
- const initAuth = () => {
- if (process.client) {
- try {
- const auth = getAuthInstance()
- if (auth) {
- onAuthStateChanged(auth, (firebaseUser) => {
- user.value = firebaseUser
- isLoading.value = false
- })
- }
- } catch (err) {
- console.error('Failed to initialize auth:', err)
- isLoading.value = false
- }
+ const authenticateWithBackend = async (idToken: string) => {
+ try {
+ const response = await $fetch<{
+ token?: string;
+ user?: Record | 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) => {
+ try {
+ const idToken = await tokenProvider();
+ await authenticateWithBackend(idToken);
+ } catch (err) {
+ console.warn("[useAuth] Failed to sync backend session", err);
+ }
+ };
+
+ const registerListener = () => {
+ if (!process.client || listenerRegistered.value) {
+ return;
+ }
+
+ try {
+ const auth = getAuthInstance();
+ if (auth) {
+ const existingUser = auth.currentUser;
+ if (existingUser && !user.value) {
+ user.value = existingUser;
+ isLoading.value = false;
+ syncBackendWithToken(() => existingUser.getIdToken());
+ }
+
+ listenerRegistered.value = true;
+ onAuthStateChanged(auth, (firebaseUser) => {
+ user.value = firebaseUser;
+ isLoading.value = false;
+ if (firebaseUser) {
+ syncBackendWithToken(() => firebaseUser.getIdToken());
+ } else {
+ backendUser.value = null;
+ }
+ });
+ }
+ } catch (err) {
+ console.error("[useAuth] Failed to initialize auth listener:", err);
+ isLoading.value = false;
+ }
+ };
+
+ const initAuth = () => {
+ registerListener();
+ };
+
+ // Eagerly register listener when composable is used in client context
+ if (process.client) {
+ registerListener();
}
// Sign in with email and password
const signInWithEmail = async (email: string, password: string) => {
try {
- error.value = null
- isLoading.value = true
-
- const auth = getAuthInstance()
+ error.value = null;
+ isLoading.value = true;
+
+ 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
-
- const auth = getAuthInstance()
- if (!auth) {
- throw new Error('Firebase not initialized')
- }
-
- const provider = new GoogleAuthProvider()
- const userCredential = await signInWithPopup(auth, provider)
- const idToken = await userCredential.user.getIdToken()
-
- try {
- await authenticateWithBackend(idToken)
- } catch (backendErr) {
- console.warn('[useAuth] Backend authentication failed after Google login:', backendErr)
- }
-
- return userCredential.user
- } catch (err: any) {
- error.value = err.message
- throw err
- } finally {
- isLoading.value = false
- }
- }
+ error.value = null;
+ isLoading.value = true;
- // Authenticate with backend
- const authenticateWithBackend = async (idToken: string) => {
- try {
- const response = await $fetch('/auth/login', {
- baseURL: config.public.backendUrl,
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: {
- idToken
- }
- })
-
- return response
- } catch (err) {
- console.error('Backend authentication failed:', err)
- throw err
+ const auth = getAuthInstance();
+ if (!auth) {
+ throw new Error("Firebase not initialized");
+ }
+
+ const provider = new GoogleAuthProvider();
+ const userCredential = await signInWithPopup(auth, provider);
+ const idToken = await userCredential.user.getIdToken();
+
+ try {
+ await authenticateWithBackend(idToken);
+ } catch (backendErr) {
+ console.warn(
+ "[useAuth] Backend authentication failed after Google login:",
+ backendErr
+ );
+ }
+
+ user.value = userCredential.user;
+ registerListener();
+
+ return userCredential.user;
+ } catch (err: any) {
+ error.value = err.message;
+ throw err;
+ } finally {
+ isLoading.value = false;
}
- }
+ };
+
+ const registerWithEmail = async (email: string, password: string) => {
+ try {
+ error.value = null;
+ isLoading.value = true;
+
+ const auth = getAuthInstance();
+ if (!auth) {
+ throw new Error("Firebase not initialized");
+ }
+
+ const userCredential = await createUserWithEmailAndPassword(
+ auth,
+ email,
+ password
+ );
+ const idToken = await userCredential.user.getIdToken();
+
+ try {
+ await authenticateWithBackend(idToken);
+ } catch (backendErr) {
+ console.warn(
+ "[useAuth] Backend authentication failed after registration:",
+ backendErr
+ );
+ }
+
+ user.value = userCredential.user;
+ registerListener();
+
+ return userCredential.user;
+ } catch (err: any) {
+ error.value = err.message;
+ throw err;
+ } finally {
+ isLoading.value = false;
+ }
+ };
// Sign out
const signOut = async () => {
try {
- const auth = getAuthInstance()
+ 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
- }
-}
\ No newline at end of file
+ getIdToken,
+ };
+};
diff --git a/app/composables/useFirebaseStorage.ts b/app/composables/useFirebaseStorage.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/app/composables/useFirebaseStorage.ts
@@ -0,0 +1 @@
+export {};
diff --git a/app/pages/checkout/success.vue b/app/pages/checkout/success.vue
new file mode 100644
index 0000000..c406d4f
--- /dev/null
+++ b/app/pages/checkout/success.vue
@@ -0,0 +1,235 @@
+
+
+
+
+
+
+
+
+
+
+
Payment Confirmed
+
+ Thank you for purchasing your custom slipmat design. We've received your payment and sent a confirmation email.
+
+
+
+
+
+
+ We couldn't find a Stripe session ID in the URL. Please return to the designer and try again.
+
+
+ Back to Designer
+
+
+
+
+
+
Order Summary
+
+
+
Total Paid
+
+ Loading...
+ {{ amountLabel }}
+ Pending
+
+
+
+
Payment Status
+
+ Loading...
+
+ {{ session.paymentStatus === 'paid' ? 'Paid' : session.paymentStatus }}
+
+ Unknown
+
+
+
+
Receipt sent to
+ {{ session.customerEmail }}
+
+
+
Session ID
+
+ {{ sessionId }}
+
+ {{ copyStatus === 'copied' ? 'Copied' : 'Copy' }}
+
+
+
+
+
+
+ Recording your transaction...
+
+
+ {{ transactionMessage || 'We could not record this transaction. Please contact support with your session ID.' }}
+
+
+ Transaction stored safely.
+
+
+
+ {{ error.message || 'We couldn\'t load the session details. Please contact support if this persists.' }}
+
+
+
+
+
What's next?
+
+
+ We'll email you a confirmation that includes your payment details and a link to download the production-ready files once they're generated.
+
+
+ Need to tweak the design? Use the button below to reopen this project; your saved layout will load automatically.
+
+
+ Have questions? Reply directly to the confirmation email and our team will help out.
+
+
+
+ Back to Designer
+
+
+
+
+
+
diff --git a/app/pages/index.vue b/app/pages/index.vue
index fbf7f36..29eb19e 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -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(
+ typeof route.query.designId === "string" ? route.query.designId : null
+);
+const loadedDesignId = ref(null);
+const isDesignLoading = ref(false);
const isCheckoutPending = ref(false);
const checkoutError = ref(null);
const lastExportedDesign = ref(null);
+type LoadDesignInput = Parameters[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 => {
+ 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("/storage/upload", {
+ baseURL: storageUrl,
+ method: "POST",
+ headers: {
+ "content-type": "multipart/form-data",
+ },
+ body: formData,
+ });
+};
+
+const persistDesign = async (designId: string, design: ExportedDesign) => {
+ const safeDesignIdBase = designId.replace(/[^a-zA-Z0-9_-]/g, "-") || "design";
+ const assetBasePath = `designs/${safeDesignIdBase}`;
+ const timestamp = Date.now();
+
+ if (!design.previewBlob || !design.productionBlob) {
+ throw new Error("Design assets are missing; please export again.");
+ }
+
+ const canvasJsonString =
+ typeof design.canvasJson === "string"
+ ? design.canvasJson
+ : JSON.stringify(design.canvasJson);
+
+ const prefix = `${assetBasePath}/`;
+
+ const [previewUpload, productionUpload, canvasUpload] = await Promise.all([
+ uploadDesignAsset(design.previewBlob, `preview-${timestamp}.png`, {
+ prefix,
+ }),
+ uploadDesignAsset(design.productionBlob, `production-${timestamp}.png`, {
+ prefix,
+ }),
+ uploadDesignAsset(
+ new Blob([canvasJsonString], { type: "application/json" }),
+ `canvas-${timestamp}.json`,
+ { prefix }
+ ),
+ ]);
+
+ await $fetch("/api/designs", {
+ method: "POST",
+ body: {
+ designId,
+ templateId: design.templateId,
+ ownerEmail: user.value?.email ?? null,
+ ownerId: backendUser.value?.id ?? null,
+ previewUrl: previewUpload.publicUrl,
+ productionUrl: productionUpload.publicUrl,
+ canvasJson: canvasUpload.publicUrl,
+ metadata: {
+ designName: templateLabel.value,
+ storage: {
+ preview: {
+ objectName: previewUpload.objectName,
+ bucket: previewUpload.bucket,
+ },
+ production: {
+ objectName: productionUpload.objectName,
+ bucket: productionUpload.bucket,
+ },
+ canvas: {
+ objectName: canvasUpload.objectName,
+ bucket: canvasUpload.bucket,
+ },
+ },
+ storagePaths: {
+ preview: previewUpload.objectName,
+ production: productionUpload.objectName,
+ canvas: canvasUpload.objectName,
+ },
+ },
+ },
+ });
+
+ return {
+ previewUrl: previewUpload.publicUrl,
+ productionUrl: productionUpload.publicUrl,
+ canvasJsonUrl: canvasUpload.publicUrl,
+ };
+};
+
+const loadDesignById = async (designId: string) => {
+ if (!process.client) {
+ return;
+ }
+
+ isDesignLoading.value = true;
+ try {
+ const design = await $fetch<{
+ designId?: string;
+ templateId?: string | null;
+ previewUrl?: string | null;
+ productionUrl?: string | null;
+ canvasJson?: unknown;
+ }>(`/api/designs/${designId}`);
+
+ if (!design?.canvasJson) {
+ throw new Error("Saved design is missing canvas data.");
+ }
+
+ let canvasPayload: LoadDesignInput["canvasJson"] =
+ design.canvasJson as LoadDesignInput["canvasJson"];
+
+ if (typeof design.canvasJson === "string") {
+ const trimmed = design.canvasJson.trim();
+ const isRemoteSource = /^https?:\/\//i.test(trimmed);
+
+ if (isRemoteSource) {
+ const response = await fetch(trimmed);
+ if (!response.ok) {
+ throw new Error("Failed to download saved canvas data.");
+ }
+ canvasPayload =
+ (await response.text()) as LoadDesignInput["canvasJson"];
+ } else {
+ canvasPayload = trimmed as LoadDesignInput["canvasJson"];
+ }
+ }
+
+ await loadDesign({
+ templateId: design.templateId ?? null,
+ canvasJson: canvasPayload,
+ previewUrl: design.previewUrl ?? null,
+ });
+
+ loadedDesignId.value = designId;
+ lastExportedDesign.value = null;
+ } catch (error: any) {
+ console.error("Failed to load saved design", error);
+ } finally {
+ isDesignLoading.value = false;
+ }
+};
+
+watch(
+ () => route.query.designId,
+ (value) => {
+ const nextId = typeof value === "string" ? value : null;
+ if (nextId !== activeDesignId.value) {
+ activeDesignId.value = nextId;
+ }
+ }
+);
+
+watch(
+ activeDesignId,
+ (designId) => {
+ if (!designId || designId === loadedDesignId.value) {
+ return;
+ }
+ loadDesignById(designId);
+ },
+ { immediate: true }
+);
+
+onMounted(() => {
+ initAuth();
+});
+
const handleTemplateSelect = (templateId: string) => {
selectTemplate(templateId);
};
@@ -64,6 +266,18 @@ const handleCheckout = async () => {
return;
}
+ initAuth();
+
+ if (isLoading.value) {
+ for (
+ let attempt = 0;
+ attempt < 10 && isLoading.value && !user.value;
+ attempt += 1
+ ) {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ }
+
if (!user.value) {
loginModal.value = true;
return;
@@ -83,26 +297,42 @@ const handleCheckout = async () => {
lastExportedDesign.value = exportResult;
const designId =
- typeof crypto !== "undefined" && "randomUUID" in crypto
+ activeDesignId.value ??
+ (typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
- : `design-${Date.now()}`;
+ : `design-${Date.now()}`);
+
+ await persistDesign(designId, exportResult);
+
+ activeDesignId.value = designId;
+ loadedDesignId.value = designId;
+
+ if (
+ typeof route.query.designId !== "string" ||
+ route.query.designId !== designId
+ ) {
+ await router.replace({ query: { designId } });
+ }
const successUrlTemplate = `${window.location.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = window.location.href;
- const session = await $fetch<{ id: string; url?: string | null }>("/api/checkout.session", {
- method: "POST",
- body: {
- designId,
- templateId: exportResult.templateId,
- designName: templateLabel.value,
- amount: DESIGN_PRICE_USD,
- currency: "usd",
- successUrl: successUrlTemplate,
- cancelUrl,
- customerEmail: user.value?.email,
- },
- });
+ const session = await $fetch<{ id: string; url?: string | null }>(
+ "/api/checkout.session",
+ {
+ method: "POST",
+ body: {
+ designId,
+ templateId: exportResult.templateId,
+ designName: templateLabel.value,
+ amount: DESIGN_PRICE_USD,
+ currency: "usd",
+ successUrl: successUrlTemplate,
+ cancelUrl,
+ customerEmail: user.value?.email ?? undefined,
+ },
+ }
+ );
if (!session?.id) {
throw new Error("Stripe session could not be created.");
@@ -113,11 +343,15 @@ const handleCheckout = async () => {
return;
}
- const fallbackRedirect = successUrlTemplate.replace("{CHECKOUT_SESSION_ID}", session.id);
+ const fallbackRedirect = successUrlTemplate.replace(
+ "{CHECKOUT_SESSION_ID}",
+ session.id
+ );
window.location.href = fallbackRedirect;
} catch (err: any) {
console.error("Checkout failed", err);
- checkoutError.value = err?.message ?? "Unable to start checkout. Please try again.";
+ checkoutError.value =
+ err?.message ?? "Unable to start checkout. Please try again.";
} finally {
isCheckoutPending.value = false;
}
@@ -169,7 +403,7 @@ const handleCheckout = async () => {
:preview-url="previewUrl"
:template-label="templateLabel"
:production-pixels="productionPixelSize"
- :is-exporting="isExporting"
+ :is-exporting="isExporting || isDesignLoading"
:is-checkout-pending="isCheckoutPending"
:checkout-price="DESIGN_PRICE_USD"
:checkout-error="checkoutError"
@@ -181,7 +415,9 @@ const handleCheckout = async () => {
-
+
{
/>
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.
diff --git a/app/pages/orders.vue b/app/pages/orders.vue
new file mode 100644
index 0000000..3493e2d
--- /dev/null
+++ b/app/pages/orders.vue
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Sign in to view orders
+
+ Orders are associated with your Slipmatz account. Sign in to see the designs you've purchased.
+
+
+ Sign in
+
+
+
+
+
+
+ Signed in as {{ customerEmail }}
+
+
+ {{ ordersLoading ? 'Refreshing…' : 'Refresh' }}
+
+
+
+
+ {{ ordersError }}
+
+
+
+
+
+ No orders yet. When you complete a purchase, your history will show up here for easy reference.
+
+
+
+
+
+
+
+ {{ formatAmount(order) }}
+
+
+ {{ order.currency ? order.currency.toUpperCase() : 'USD' }} • {{ formatDate(order.createdAt || order.updatedAt) }}
+
+
+
+ {{ order.status }}
+
+
+
+
+
+
Design ID
+ {{ order.designId }}
+
+
+
Template
+ {{ order.templateId }}
+
+
+
Receipt sent to
+ {{ order.customerEmail }}
+
+
+
Stripe session
+ {{ order.stripeSessionId }}
+
+
+
+
+ Reopen design
+ →
+
+
+
+
+
+
+
diff --git a/app/pages/profile.vue b/app/pages/profile.vue
new file mode 100644
index 0000000..8012c3d
--- /dev/null
+++ b/app/pages/profile.vue
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
You're signed out
+
+ Sign in to view your profile information and order history.
+
+
+ Sign in
+
+
+
+
+
+
+
+
{{ displayName }}
+
{{ displayEmail }}
+
+
+
+ View order history
+
+
+ Sign out
+
+
+
+
+
+
+
Account details
+
+
+
{{ field.label }}
+ {{ field.value }}
+
+
+
+
+
+
Backend session
+
+ The following data is provided by the Slipmatz backend and may include additional metadata used for order fulfillment.
+
+
+{{ JSON.stringify(backendUser, null, 2) }}
+
+
+
+
+
+
diff --git a/app/pages/register.vue b/app/pages/register.vue
new file mode 100644
index 0000000..3dd7484
--- /dev/null
+++ b/app/pages/register.vue
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+
+ Create Account
+ Join Slipmatz
+
+ Sign up with email and password to save your designs and return to them anytime.
+
+
+
+
+
+
+ Already have an account?
+
+ Sign in instead
+
+
+
+
+
diff --git a/composables/useSlipmatDesigner.ts b/composables/useSlipmatDesigner.ts
index f978400..111743f 100644
--- a/composables/useSlipmatDesigner.ts
+++ b/composables/useSlipmatDesigner.ts
@@ -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
;
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 => {
+ if (canvas.value) {
+ return canvas.value;
+ }
+
+ await new Promise((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((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 => {
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,
@@ -697,9 +774,9 @@ export const useSlipmatDesigner = () => {
productionBlob,
productionObjectUrl,
isExporting,
- activeFillColor,
- activeStrokeColor,
- canStyleSelection,
+ activeFillColor,
+ activeStrokeColor,
+ canStyleSelection,
zoomLevel,
zoomPercent,
minZoom: MIN_ZOOM,
@@ -709,8 +786,8 @@ export const useSlipmatDesigner = () => {
addTextbox,
addShape,
addImageFromFile,
- setActiveFillColor,
- setActiveStrokeColor,
+ setActiveFillColor,
+ setActiveStrokeColor,
setZoom,
zoomIn,
zoomOut,
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 69fc01e..72fe6ae 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -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: {
diff --git a/server/api/checkout.session.post.ts b/server/api/checkout.session.post.ts
index b88eefb..29328ff 100644
--- a/server/api/checkout.session.post.ts
+++ b/server/api/checkout.session.post.ts
@@ -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 };
+});
diff --git a/server/api/checkout/[sessionId].get.ts b/server/api/checkout/[sessionId].get.ts
new file mode 100644
index 0000000..21f6029
--- /dev/null
+++ b/server/api/checkout/[sessionId].get.ts
@@ -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',
+ })
+ }
+})
diff --git a/server/api/designs.post.ts b/server/api/designs.post.ts
new file mode 100644
index 0000000..5b688f3
--- /dev/null
+++ b/server/api/designs.post.ts
@@ -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;
+ }>(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",
+ });
+ }
+});
diff --git a/server/api/designs/[designId].get.ts b/server/api/designs/[designId].get.ts
new file mode 100644
index 0000000..99e8713
--- /dev/null
+++ b/server/api/designs/[designId].get.ts
@@ -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",
+ })
+ }
+})
diff --git a/server/api/orders.get.ts b/server/api/orders.get.ts
new file mode 100644
index 0000000..8ab97b6
--- /dev/null
+++ b/server/api/orders.get.ts
@@ -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",
+ });
+ }
+});
diff --git a/server/api/transactions.post.ts b/server/api/transactions.post.ts
index d52f803..b771b5e 100644
--- a/server/api/transactions.post.ts
+++ b/server/api/transactions.post.ts
@@ -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;
- return {
- ok: true,
- receivedAt: new Date().toISOString(),
+ 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",
+ });
+ }
+});