diff --git a/.env.example b/.env.example index 43fc1e1..d3f11ed 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,17 @@ # Firebase Configuration # Get these values from your Firebase Console -> Project Settings -> General -> Your apps -FIREBASE_API_KEY=your_firebase_api_key_here -FIREBASE_AUTH_DOMAIN=auctions-fb598.firebaseapp.com -FIREBASE_PROJECT_ID=auctions-fb598 -FIREBASE_STORAGE_BUCKET=auctions-fb598.appspot.com -FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id_here -FIREBASE_APP_ID=your_app_id_here +NUXT_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key_here +NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_firebase_auth_domain_here +NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_firebase_project_id_here +NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_firebase_storage_bucket_here +NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id_here +NUXT_PUBLIC_FIREBASE_APP_ID=your_firebase_app_id_here +NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID=your_firebase_measurement_id_here + +# Stripe Configuration +NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx # Backend Configuration -BACKEND_URL=http://localhost:3000 \ No newline at end of file +NUXT_PUBLIC_BACKEND_URL=http://localhost:3000 \ No newline at end of file diff --git a/app/components/AppNavbar.vue b/app/components/AppNavbar.vue index 154a95f..d2bf42b 100644 --- a/app/components/AppNavbar.vue +++ b/app/components/AppNavbar.vue @@ -1,6 +1,10 @@ \ No newline at end of file + diff --git a/app/components/designer/DesignerPreview.vue b/app/components/designer/DesignerPreview.vue index 4f614a1..e6109df 100644 --- a/app/components/designer/DesignerPreview.vue +++ b/app/components/designer/DesignerPreview.vue @@ -4,12 +4,16 @@ const props = defineProps<{ templateLabel: string; productionPixels: number; isExporting: boolean; + isCheckoutPending: boolean; + checkoutPrice: number; + checkoutError: string | null; }>(); const emit = defineEmits<{ (e: "download-preview"): void; (e: "download-production"): void; (e: "export"): void; + (e: "checkout"): void; }>(); const viewMode = ref<"flat" | "turntable">("flat"); @@ -23,6 +27,9 @@ const handleSelectView = (mode: "flat" | "turntable") => { const handleExport = () => emit("export"); const handleDownloadPreview = () => emit("download-preview"); const handleDownloadProduction = () => emit("download-production"); +const handleCheckout = () => emit("checkout"); + +const priceLabel = computed(() => props.checkoutPrice.toFixed(2)); diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index 8d234a1..81f27a8 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -76,8 +76,11 @@ export const useAuth = () => { const userCredential = await signInWithEmailAndPassword(auth, email, password) const idToken = await userCredential.user.getIdToken() - // Send token to backend - await authenticateWithBackend(idToken) + try { + await authenticateWithBackend(idToken) + } catch (backendErr) { + console.warn('[useAuth] Backend authentication failed after email login:', backendErr) + } return userCredential.user } catch (err: any) { @@ -103,8 +106,11 @@ export const useAuth = () => { const userCredential = await signInWithPopup(auth, provider) const idToken = await userCredential.user.getIdToken() - // Send token to backend - await authenticateWithBackend(idToken) + try { + await authenticateWithBackend(idToken) + } catch (backendErr) { + console.warn('[useAuth] Backend authentication failed after Google login:', backendErr) + } return userCredential.user } catch (err: any) { diff --git a/app/composables/useLoginModal.ts b/app/composables/useLoginModal.ts new file mode 100644 index 0000000..20f5db9 --- /dev/null +++ b/app/composables/useLoginModal.ts @@ -0,0 +1 @@ +export const useLoginModal = () => useState('login-modal-open', () => false); diff --git a/app/pages/index.vue b/app/pages/index.vue index afa4d4b..fbf7f36 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -5,6 +5,7 @@ import DesignerToolbar from "~/components/designer/DesignerToolbar.vue"; import TemplatePicker from "~/components/designer/TemplatePicker.vue"; import { useSlipmatDesigner } from "~/composables/useSlipmatDesigner"; +import type { ExportedDesign } from "~/composables/useSlipmatDesigner"; const { templates, @@ -38,12 +39,88 @@ const { resetZoom, } = useSlipmatDesigner(); +const DESIGN_PRICE_USD = 39.99; + +const { user } = useAuth(); +const loginModal = useLoginModal(); + +const isCheckoutPending = ref(false); +const checkoutError = ref(null); +const lastExportedDesign = ref(null); + const handleTemplateSelect = (templateId: string) => { selectTemplate(templateId); }; const handleExport = async () => { - await exportDesign(); + const result = await exportDesign(); + if (result) { + lastExportedDesign.value = result; + } +}; + +const handleCheckout = async () => { + if (!process.client) { + return; + } + + if (!user.value) { + loginModal.value = true; + return; + } + + checkoutError.value = null; + isCheckoutPending.value = true; + + try { + let exportResult = await exportDesign(); + if (!exportResult) { + exportResult = lastExportedDesign.value; + } + if (!exportResult) { + throw new Error("Unable to export the current design. Please try again."); + } + lastExportedDesign.value = exportResult; + + const designId = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `design-${Date.now()}`; + + 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, + }, + }); + + if (!session?.id) { + throw new Error("Stripe session could not be created."); + } + + if (session.url) { + window.location.href = session.url; + return; + } + + const fallbackRedirect = successUrlTemplate.replace("{CHECKOUT_SESSION_ID}", session.id); + window.location.href = fallbackRedirect; + } catch (err: any) { + console.error("Checkout failed", err); + checkoutError.value = err?.message ?? "Unable to start checkout. Please try again."; + } finally { + isCheckoutPending.value = false; + } }; @@ -58,11 +135,6 @@ const handleExport = async () => {

Craft custom slipmats ready for the pressing plant.

-

- Pick a template, drop in artwork, and we’ll generate both a high-fidelity - preview and a print-ready PNG at exact specs. Everything stays within a - circular safe zone to ensure clean results on vinyl. -

@@ -98,9 +170,13 @@ const handleExport = async () => { :template-label="templateLabel" :production-pixels="productionPixelSize" :is-exporting="isExporting" + :is-checkout-pending="isCheckoutPending" + :checkout-price="DESIGN_PRICE_USD" + :checkout-error="checkoutError" @export="handleExport" @download-preview="downloadPreview" @download-production="downloadProduction" + @checkout="handleCheckout" /> diff --git a/nuxt.config.ts b/nuxt.config.ts index 74abf8a..69fc01e 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -10,6 +10,8 @@ export default defineNuxtConfig({ plugins: [tailwindcss()], }, runtimeConfig: { + stripeSecretKey: process.env.STRIPE_SECRET_KEY, + stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET, public: { firebaseApiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY, firebaseAuthDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN, @@ -18,6 +20,7 @@ export default defineNuxtConfig({ firebaseMessagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, firebaseAppId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID, firebaseMeasurementId: process.env.NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID, + stripePublishableKey: process.env.NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, backendUrl: process.env.NUXT_PUBLIC_BACKEND_URL || 'http://localhost:3000' } }, diff --git a/package-lock.json b/package-lock.json index eaf25a0..a429ca7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "fabric": "^6.0.2", "firebase": "^12.5.0", "nuxt": "^4.2.0", + "stripe": "^19.3.0", "tailwindcss": "^4.1.16", "vue": "^3.5.22", "vue-router": "^4.6.3" @@ -5117,7 +5118,6 @@ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", - "optional": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -5126,6 +5126,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -6383,7 +6399,6 @@ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", - "optional": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -6477,7 +6492,6 @@ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.4" } @@ -6487,7 +6501,6 @@ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.4" } @@ -6503,7 +6516,6 @@ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", - "optional": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -7130,7 +7142,6 @@ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", - "optional": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -7161,7 +7172,6 @@ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", - "optional": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -7290,7 +7300,6 @@ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.4" }, @@ -7347,7 +7356,6 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.4" }, @@ -8605,7 +8613,6 @@ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.4" } @@ -9249,6 +9256,18 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ofetch": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.0.tgz", @@ -10232,6 +10251,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -10793,6 +10827,78 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -11116,6 +11222,26 @@ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "license": "MIT" }, + "node_modules/stripe": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-19.3.0.tgz", + "integrity": "sha512-3MbqRkw5LXb4LWP1LgIEYxUAYhYDDU5pcHZj4Xha6VWPnN1wrUmQ7Htsgm8wR584s0hn1aQg1lYD0Hi+F37E5g==", + "license": "MIT", + "dependencies": { + "qs": "^6.11.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/structured-clone-es": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/structured-clone-es/-/structured-clone-es-1.0.0.tgz", diff --git a/package.json b/package.json index a6ced0b..6214e47 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,12 @@ "postinstall": "nuxt prepare" }, "dependencies": { - "@nuxtjs/color-mode": "^3.5.2", + "@nuxtjs/color-mode": "^3.5.2", "@tailwindcss/vite": "^4.1.16", "fabric": "^6.0.2", "firebase": "^12.5.0", "nuxt": "^4.2.0", + "stripe": "^19.3.0", "tailwindcss": "^4.1.16", "vue": "^3.5.22", "vue-router": "^4.6.3" diff --git a/server/api/checkout.session.post.ts b/server/api/checkout.session.post.ts new file mode 100644 index 0000000..b88eefb --- /dev/null +++ b/server/api/checkout.session.post.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 stripe = new Stripe(stripeSecretKey, { + apiVersion: '2024-10-28.acacia', + }) + + const body = await readBody<{ + designId: string + templateId?: string + designName?: string + amount: number + currency?: string + successUrl: string + cancelUrl: string + customerEmail?: string + }>(event) + + if (!body?.designId || !body?.amount || !body?.successUrl || !body?.cancelUrl) { + throw createError({ statusCode: 400, statusMessage: 'Missing required fields' }) + } + + const { currency = 'usd' } = body + + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + payment_method_types: ['card'], + billing_address_collection: 'auto', + customer_email: body.customerEmail, + line_items: [ + { + quantity: 1, + price_data: { + currency, + unit_amount: Math.round(body.amount * 100), + product_data: { + name: body.designName ?? `Slipmat design ${body.designId}`, + metadata: { + designId: body.designId, + ...(body.templateId ? { templateId: body.templateId } : {}), + }, + }, + }, + }, + ], + metadata: { + designId: body.designId, + ...(body.templateId ? { templateId: body.templateId } : {}), + }, + success_url: body.successUrl, + cancel_url: body.cancelUrl, + }) + + return { id: session.id, url: session.url } +}) diff --git a/server/api/transactions.post.ts b/server/api/transactions.post.ts new file mode 100644 index 0000000..d52f803 --- /dev/null +++ b/server/api/transactions.post.ts @@ -0,0 +1,36 @@ +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) + + if (!body?.stripeSessionId || !body?.designId) { + 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, + // }) + + return { + ok: true, + receivedAt: new Date().toISOString(), + } +})