feat: implement Stripe checkout integration and add related API endpoints

This commit is contained in:
Frank John Begornia
2025-11-08 01:47:14 +08:00
parent 86f9cf803a
commit 0ff41822af
12 changed files with 443 additions and 97 deletions

View File

@@ -1,11 +1,17 @@
# Firebase Configuration # Firebase Configuration
# Get these values from your Firebase Console -> Project Settings -> General -> Your apps # Get these values from your Firebase Console -> Project Settings -> General -> Your apps
FIREBASE_API_KEY=your_firebase_api_key_here NUXT_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key_here
FIREBASE_AUTH_DOMAIN=auctions-fb598.firebaseapp.com NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_firebase_auth_domain_here
FIREBASE_PROJECT_ID=auctions-fb598 NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_firebase_project_id_here
FIREBASE_STORAGE_BUCKET=auctions-fb598.appspot.com NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_firebase_storage_bucket_here
FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id_here NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id_here
FIREBASE_APP_ID=your_app_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 Configuration
BACKEND_URL=http://localhost:3000 NUXT_PUBLIC_BACKEND_URL=http://localhost:3000

View File

@@ -1,6 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
const { user, signOut, initAuth, isLoading } = useAuth() const { user, signOut, initAuth, isLoading } = useAuth()
const showLoginModal = ref(false) const loginModal = useLoginModal()
const openLoginModal = () => {
loginModal.value = true
}
// Initialize auth on component mount // Initialize auth on component mount
onMounted(() => { onMounted(() => {
@@ -38,7 +42,7 @@ const handleSignOut = async () => {
<div v-else-if="!isLoading" class="flex items-center gap-3"> <div v-else-if="!isLoading" class="flex items-center gap-3">
<button <button
type="button" type="button"
@click="showLoginModal = true" @click="openLoginModal"
class="rounded-full border border-slate-700/80 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-slate-500 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950" class="rounded-full border border-slate-700/80 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-slate-500 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
> >
Login Login
@@ -53,6 +57,6 @@ const handleSignOut = async () => {
</div> </div>
</div> </div>
<LoginModal v-model="showLoginModal" /> <LoginModal />
</nav> </nav>
</template> </template>

View File

@@ -1,40 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
const { signInWithEmail, signInWithGoogle, error } = useAuth() const { signInWithEmail, signInWithGoogle, error } = useAuth();
const isOpen = defineModel<boolean>() const isOpen = useLoginModal();
const email = ref('') const email = ref("");
const password = ref('') const password = ref("");
const loginError = ref<string | null>(null) const loginError = ref<string | null>(null);
const isSubmitting = ref(false) const isSubmitting = ref(false);
const handleEmailLogin = async () => { const handleEmailLogin = async () => {
try { try {
loginError.value = null loginError.value = null;
isSubmitting.value = true isSubmitting.value = true;
await signInWithEmail(email.value, password.value) await signInWithEmail(email.value, password.value);
isOpen.value = false isOpen.value = false;
// Reset form // Reset form
email.value = '' email.value = "";
password.value = '' password.value = "";
} catch (err) { } catch (err) {
loginError.value = 'Login failed. Please check your credentials.' loginError.value = "Login failed. Please check your credentials.";
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false;
}
} }
};
const handleGoogleLogin = async () => { const handleGoogleLogin = async () => {
try { try {
loginError.value = null loginError.value = null;
isSubmitting.value = true isSubmitting.value = true;
await signInWithGoogle() await signInWithGoogle();
isOpen.value = false isOpen.value = false;
} catch (err) { } catch (err) {
loginError.value = 'Google login failed. Please try again.' loginError.value = "Google login failed. Please try again.";
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false;
}
} }
};
</script> </script>
<template> <template>
@@ -49,7 +49,9 @@ const handleGoogleLogin = async () => {
<form @submit.prevent="handleEmailLogin" class="space-y-4"> <form @submit.prevent="handleEmailLogin" class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-slate-300">Email</label> <label class="block text-sm font-medium text-slate-300"
>Email</label
>
<input <input
v-model="email" v-model="email"
type="email" type="email"
@@ -59,7 +61,9 @@ const handleGoogleLogin = async () => {
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-300">Password</label> <label class="block text-sm font-medium text-slate-300"
>Password</label
>
<input <input
v-model="password" v-model="password"
type="password" type="password"
@@ -73,7 +77,7 @@ const handleGoogleLogin = async () => {
:disabled="isSubmitting" :disabled="isSubmitting"
class="w-full rounded-md bg-sky-600 px-4 py-2 text-white hover:bg-sky-700 disabled:opacity-50" class="w-full rounded-md bg-sky-600 px-4 py-2 text-white hover:bg-sky-700 disabled:opacity-50"
> >
{{ isSubmitting ? 'Signing in...' : 'Sign In' }} {{ isSubmitting ? "Signing in..." : "Sign In" }}
</button> </button>
</form> </form>

View File

@@ -4,12 +4,16 @@ const props = defineProps<{
templateLabel: string; templateLabel: string;
productionPixels: number; productionPixels: number;
isExporting: boolean; isExporting: boolean;
isCheckoutPending: boolean;
checkoutPrice: number;
checkoutError: string | null;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "download-preview"): void; (e: "download-preview"): void;
(e: "download-production"): void; (e: "download-production"): void;
(e: "export"): void; (e: "export"): void;
(e: "checkout"): void;
}>(); }>();
const viewMode = ref<"flat" | "turntable">("flat"); const viewMode = ref<"flat" | "turntable">("flat");
@@ -23,6 +27,9 @@ const handleSelectView = (mode: "flat" | "turntable") => {
const handleExport = () => emit("export"); const handleExport = () => emit("export");
const handleDownloadPreview = () => emit("download-preview"); const handleDownloadPreview = () => emit("download-preview");
const handleDownloadProduction = () => emit("download-production"); const handleDownloadProduction = () => emit("download-production");
const handleCheckout = () => emit("checkout");
const priceLabel = computed(() => props.checkoutPrice.toFixed(2));
</script> </script>
<template> <template>
@@ -112,6 +119,20 @@ const handleDownloadProduction = () => emit("download-production");
> >
Download Print-Ready PNG Download Print-Ready PNG
</button> </button>
<button
type="button"
class="flex-1 rounded-xl bg-emerald-500 px-4 py-3 text-sm font-semibold text-emerald-950 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:bg-emerald-500/60 disabled:text-emerald-900/60"
:disabled="props.isCheckoutPending || props.isExporting"
@click="handleCheckout"
>
{{ props.isCheckoutPending ? "Redirecting…" : `Buy This Design ($${priceLabel})` }}
</button>
</div>
<div
v-if="props.checkoutError"
class="mt-3 rounded-xl border border-rose-500/60 bg-rose-500/10 px-4 py-3 text-sm text-rose-200"
>
{{ props.checkoutError }}
</div> </div>
</section> </section>
</template> </template>

View File

@@ -76,8 +76,11 @@ export const useAuth = () => {
const userCredential = await signInWithEmailAndPassword(auth, email, password) const userCredential = await signInWithEmailAndPassword(auth, email, password)
const idToken = await userCredential.user.getIdToken() const idToken = await userCredential.user.getIdToken()
// Send token to backend try {
await authenticateWithBackend(idToken) await authenticateWithBackend(idToken)
} catch (backendErr) {
console.warn('[useAuth] Backend authentication failed after email login:', backendErr)
}
return userCredential.user return userCredential.user
} catch (err: any) { } catch (err: any) {
@@ -103,8 +106,11 @@ export const useAuth = () => {
const userCredential = await signInWithPopup(auth, provider) const userCredential = await signInWithPopup(auth, provider)
const idToken = await userCredential.user.getIdToken() const idToken = await userCredential.user.getIdToken()
// Send token to backend try {
await authenticateWithBackend(idToken) await authenticateWithBackend(idToken)
} catch (backendErr) {
console.warn('[useAuth] Backend authentication failed after Google login:', backendErr)
}
return userCredential.user return userCredential.user
} catch (err: any) { } catch (err: any) {

View File

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

View File

@@ -5,6 +5,7 @@ import DesignerToolbar from "~/components/designer/DesignerToolbar.vue";
import TemplatePicker from "~/components/designer/TemplatePicker.vue"; import TemplatePicker from "~/components/designer/TemplatePicker.vue";
import { useSlipmatDesigner } from "~/composables/useSlipmatDesigner"; import { useSlipmatDesigner } from "~/composables/useSlipmatDesigner";
import type { ExportedDesign } from "~/composables/useSlipmatDesigner";
const { const {
templates, templates,
@@ -38,12 +39,88 @@ const {
resetZoom, resetZoom,
} = useSlipmatDesigner(); } = useSlipmatDesigner();
const DESIGN_PRICE_USD = 39.99;
const { user } = useAuth();
const loginModal = useLoginModal();
const isCheckoutPending = ref(false);
const checkoutError = ref<string | null>(null);
const lastExportedDesign = ref<ExportedDesign | null>(null);
const handleTemplateSelect = (templateId: string) => { const handleTemplateSelect = (templateId: string) => {
selectTemplate(templateId); selectTemplate(templateId);
}; };
const handleExport = async () => { 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;
}
}; };
</script> </script>
@@ -58,11 +135,6 @@ const handleExport = async () => {
<h1 class="text-3xl font-bold text-white sm:text-4xl"> <h1 class="text-3xl font-bold text-white sm:text-4xl">
Craft custom slipmats ready for the pressing plant. Craft custom slipmats ready for the pressing plant.
</h1> </h1>
<p class="max-w-3xl text-base text-slate-300">
Pick a template, drop in artwork, and well 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.
</p>
</header> </header>
<section class="mt-10 grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]"> <section class="mt-10 grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]">
@@ -98,9 +170,13 @@ const handleExport = async () => {
:template-label="templateLabel" :template-label="templateLabel"
:production-pixels="productionPixelSize" :production-pixels="productionPixelSize"
:is-exporting="isExporting" :is-exporting="isExporting"
:is-checkout-pending="isCheckoutPending"
:checkout-price="DESIGN_PRICE_USD"
:checkout-error="checkoutError"
@export="handleExport" @export="handleExport"
@download-preview="downloadPreview" @download-preview="downloadPreview"
@download-production="downloadProduction" @download-production="downloadProduction"
@checkout="handleCheckout"
/> />
</div> </div>

View File

@@ -10,6 +10,8 @@ export default defineNuxtConfig({
plugins: [tailwindcss()], plugins: [tailwindcss()],
}, },
runtimeConfig: { runtimeConfig: {
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
public: { public: {
firebaseApiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY, firebaseApiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY,
firebaseAuthDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN, firebaseAuthDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
@@ -18,6 +20,7 @@ export default defineNuxtConfig({
firebaseMessagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, firebaseMessagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
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,
backendUrl: process.env.NUXT_PUBLIC_BACKEND_URL || 'http://localhost:3000' backendUrl: process.env.NUXT_PUBLIC_BACKEND_URL || 'http://localhost:3000'
} }
}, },

146
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"fabric": "^6.0.2", "fabric": "^6.0.2",
"firebase": "^12.5.0", "firebase": "^12.5.0",
"nuxt": "^4.2.0", "nuxt": "^4.2.0",
"stripe": "^19.3.0",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
@@ -5117,7 +5118,6 @@
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -5126,6 +5126,22 @@
"node": ">= 0.4" "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": { "node_modules/caniuse-api": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -6477,7 +6492,6 @@
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -6487,7 +6501,6 @@
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -6503,7 +6516,6 @@
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
}, },
@@ -7130,7 +7142,6 @@
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1", "es-define-property": "^1.0.1",
@@ -7161,7 +7172,6 @@
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"dunder-proto": "^1.0.1", "dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0" "es-object-atoms": "^1.0.0"
@@ -7290,7 +7300,6 @@
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -7347,7 +7356,6 @@
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -8605,7 +8613,6 @@
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -9249,6 +9256,18 @@
"node": ">=0.10.0" "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": { "node_modules/ofetch": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.0.tgz", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.0.tgz",
@@ -10232,6 +10251,21 @@
"node": ">=6" "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": { "node_modules/quansync": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@@ -10793,6 +10827,78 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -11116,6 +11222,26 @@
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"license": "MIT" "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": { "node_modules/structured-clone-es": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/structured-clone-es/-/structured-clone-es-1.0.0.tgz", "resolved": "https://registry.npmjs.org/structured-clone-es/-/structured-clone-es-1.0.0.tgz",

View File

@@ -15,6 +15,7 @@
"fabric": "^6.0.2", "fabric": "^6.0.2",
"firebase": "^12.5.0", "firebase": "^12.5.0",
"nuxt": "^4.2.0", "nuxt": "^4.2.0",
"stripe": "^19.3.0",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"

View File

@@ -0,0 +1,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 }
})

View File

@@ -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(),
}
})