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,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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) {

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 { 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 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>
<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>