feat: implement Stripe checkout integration and add related API endpoints
This commit is contained in:
20
.env.example
20
.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
|
||||
NUXT_PUBLIC_BACKEND_URL=http://localhost:3000
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
const { user, signOut, initAuth, isLoading } = useAuth()
|
||||
const showLoginModal = ref(false)
|
||||
const loginModal = useLoginModal()
|
||||
|
||||
const openLoginModal = () => {
|
||||
loginModal.value = true
|
||||
}
|
||||
|
||||
// Initialize auth on component mount
|
||||
onMounted(() => {
|
||||
@@ -38,7 +42,7 @@ const handleSignOut = async () => {
|
||||
<div v-else-if="!isLoading" class="flex items-center gap-3">
|
||||
<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"
|
||||
>
|
||||
Login
|
||||
@@ -53,6 +57,6 @@ const handleSignOut = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginModal v-model="showLoginModal" />
|
||||
<LoginModal />
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
const { signInWithEmail, signInWithGoogle, error } = useAuth()
|
||||
const { signInWithEmail, signInWithGoogle, error } = useAuth();
|
||||
|
||||
const isOpen = defineModel<boolean>()
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const loginError = ref<string | null>(null)
|
||||
const isSubmitting = ref(false)
|
||||
const isOpen = useLoginModal();
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const loginError = ref<string | null>(null);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const handleEmailLogin = async () => {
|
||||
try {
|
||||
loginError.value = null
|
||||
isSubmitting.value = true
|
||||
await signInWithEmail(email.value, password.value)
|
||||
isOpen.value = false
|
||||
loginError.value = null;
|
||||
isSubmitting.value = true;
|
||||
await signInWithEmail(email.value, password.value);
|
||||
isOpen.value = false;
|
||||
// Reset form
|
||||
email.value = ''
|
||||
password.value = ''
|
||||
email.value = "";
|
||||
password.value = "";
|
||||
} catch (err) {
|
||||
loginError.value = 'Login failed. Please check your credentials.'
|
||||
loginError.value = "Login failed. Please check your credentials.";
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
try {
|
||||
loginError.value = null
|
||||
isSubmitting.value = true
|
||||
await signInWithGoogle()
|
||||
isOpen.value = false
|
||||
loginError.value = null;
|
||||
isSubmitting.value = true;
|
||||
await signInWithGoogle();
|
||||
isOpen.value = false;
|
||||
} catch (err) {
|
||||
loginError.value = 'Google login failed. Please try again.'
|
||||
loginError.value = "Google login failed. Please try again.";
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,56 +45,60 @@ const handleGoogleLogin = async () => {
|
||||
@click.self="isOpen = false"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-lg bg-slate-900 p-6 shadow-xl">
|
||||
<h2 class="mb-6 text-2xl font-bold text-white">Sign In</h2>
|
||||
|
||||
<form @submit.prevent="handleEmailLogin" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300">Email</label>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
|
||||
/>
|
||||
<h2 class="mb-6 text-2xl font-bold text-white">Sign In</h2>
|
||||
|
||||
<form @submit.prevent="handleEmailLogin" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300"
|
||||
>Email</label
|
||||
>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300"
|
||||
>Password</label
|
||||
>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
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" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="my-4 flex items-center">
|
||||
<div class="flex-1 border-t border-slate-700"></div>
|
||||
<span class="px-3 text-sm text-slate-400">or</span>
|
||||
<div class="flex-1 border-t border-slate-700"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300">Password</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
@click="handleGoogleLogin"
|
||||
: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 border border-slate-700 bg-slate-800 px-4 py-2 text-white hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{{ isSubmitting ? 'Signing in...' : 'Sign In' }}
|
||||
Sign in with Google
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="my-4 flex items-center">
|
||||
<div class="flex-1 border-t border-slate-700"></div>
|
||||
<span class="px-3 text-sm text-slate-400">or</span>
|
||||
<div class="flex-1 border-t border-slate-700"></div>
|
||||
</div>
|
||||
<div v-if="loginError || error" class="mt-4 text-sm text-red-400">
|
||||
{{ loginError || error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleGoogleLogin"
|
||||
:disabled="isSubmitting"
|
||||
class="w-full rounded-md border border-slate-700 bg-slate-800 px-4 py-2 text-white hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
Sign in with Google
|
||||
</button>
|
||||
|
||||
<div v-if="loginError || error" class="mt-4 text-sm text-red-400">
|
||||
{{ loginError || error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="isOpen = false"
|
||||
class="mt-4 text-sm text-slate-400 hover:text-white"
|
||||
@@ -104,4 +108,4 @@ const handleGoogleLogin = async () => {
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -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));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -112,6 +119,20 @@ const handleDownloadProduction = () => emit("download-production");
|
||||
>
|
||||
Download Print-Ready PNG
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
1
app/composables/useLoginModal.ts
Normal file
1
app/composables/useLoginModal.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const useLoginModal = () => useState<boolean>('login-modal-open', () => false);
|
||||
@@ -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<string | null>(null);
|
||||
const lastExportedDesign = ref<ExportedDesign | null>(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;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -58,11 +135,6 @@ const handleExport = async () => {
|
||||
<h1 class="text-3xl font-bold text-white sm:text-4xl">
|
||||
Craft custom slipmats ready for the pressing plant.
|
||||
</h1>
|
||||
<p class="max-w-3xl text-base text-slate-300">
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="mt-10 grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
},
|
||||
|
||||
146
package-lock.json
generated
146
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
62
server/api/checkout.session.post.ts
Normal file
62
server/api/checkout.session.post.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 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 }
|
||||
})
|
||||
36
server/api/transactions.post.ts
Normal file
36
server/api/transactions.post.ts
Normal 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(),
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user