This commit is contained in:
5
app/app.vue
Normal file
5
app/app.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
14
app/assets/css/main.css
Normal file
14
app/assets/css/main.css
Normal file
@@ -0,0 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--animate-spin-slow: spin 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
157
app/components/AppNavbar.vue
Normal file
157
app/components/AppNavbar.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
const { user, backendUser, signOut, initAuth, isLoading } = useAuth()
|
||||
const loginModal = useLoginModal()
|
||||
const route = useRoute()
|
||||
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const showMenu = ref(false)
|
||||
|
||||
const displayName = computed(() => backendUser.value?.name || user.value?.displayName || backendUser.value?.email || user.value?.email || "Account")
|
||||
|
||||
const avatarInitials = computed(() => {
|
||||
const source = displayName.value
|
||||
if (!source) {
|
||||
return "S"
|
||||
}
|
||||
const parts = source.trim().split(/\s+/)
|
||||
if (parts.length === 1) {
|
||||
return parts[0].charAt(0).toUpperCase()
|
||||
}
|
||||
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
|
||||
})
|
||||
|
||||
const openLoginModal = () => {
|
||||
loginModal.value = true
|
||||
}
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut()
|
||||
showMenu.value = false
|
||||
} catch (error) {
|
||||
console.error('Sign out failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMenu = () => {
|
||||
showMenu.value = !showMenu.value
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
if (showMenu.value) {
|
||||
showMenu.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!dropdownRef.value) {
|
||||
return
|
||||
}
|
||||
if (!dropdownRef.value.contains(event.target as Node)) {
|
||||
closeMenu()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initAuth()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
closeMenu()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur shadow-sm">
|
||||
<div class="mx-auto max-w-6xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<NuxtLink to="/" class="text-lg font-semibold text-slate-900 transition hover:text-slate-600 sm:text-xl">
|
||||
TableJerseys
|
||||
</NuxtLink>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Show user info and logout when authenticated -->
|
||||
<div v-if="user && !isLoading" ref="dropdownRef" class="relative flex items-center gap-3">
|
||||
<span class="hidden text-sm text-slate-700 sm:inline">{{ backendUser?.email || user.email }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-slate-900 text-sm font-semibold uppercase text-white transition hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
@click.stop="toggleMenu"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="showMenu"
|
||||
>
|
||||
{{ avatarInitials }}
|
||||
</button>
|
||||
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="showMenu"
|
||||
class="absolute right-0 top-12 w-56 rounded-2xl border border-slate-200 bg-white p-3 shadow-xl backdrop-blur"
|
||||
role="menu"
|
||||
>
|
||||
<p class="px-3 text-xs uppercase tracking-[0.25em] text-slate-500">Signed in as</p>
|
||||
<p class="px-3 text-sm font-medium text-slate-900">{{ displayName }}</p>
|
||||
<p class="px-3 text-xs text-slate-600">{{ backendUser?.email || user.email }}</p>
|
||||
<div class="my-3 h-px bg-slate-200"></div>
|
||||
<NuxtLink
|
||||
to="/profile"
|
||||
class="flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
|
||||
role="menuitem"
|
||||
>
|
||||
Profile
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/orders"
|
||||
class="flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
|
||||
role="menuitem"
|
||||
>
|
||||
Orders
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-50"
|
||||
@click="handleSignOut"
|
||||
role="menuitem"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Show login button when not authenticated -->
|
||||
<div v-else-if="!isLoading" class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="openLoginModal"
|
||||
class="rounded-full border-2 border-slate-900 bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-white hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else class="flex items-center gap-3">
|
||||
<div class="h-8 w-16 animate-pulse rounded bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginModal />
|
||||
</nav>
|
||||
</template>
|
||||
122
app/components/LoginModal.vue
Normal file
122
app/components/LoginModal.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
const { signInWithEmail, signInWithGoogle, error } = useAuth();
|
||||
|
||||
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;
|
||||
// Reset form
|
||||
email.value = "";
|
||||
password.value = "";
|
||||
} catch (err) {
|
||||
loginError.value = "Login failed. Please check your credentials.";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
try {
|
||||
loginError.value = null;
|
||||
isSubmitting.value = true;
|
||||
await signInWithGoogle();
|
||||
isOpen.value = false;
|
||||
} catch (err) {
|
||||
loginError.value = "Google login failed. Please try again.";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed left-0 top-0 z-50 flex min-h-screen w-full items-center justify-center bg-black/60 px-4"
|
||||
@click.self="isOpen = false"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h2 class="mb-6 text-2xl font-bold text-slate-900">Sign In</h2>
|
||||
|
||||
<form @submit.prevent="handleEmailLogin" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700"
|
||||
>Email</label
|
||||
>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700"
|
||||
>Password</label
|
||||
>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
class="w-full rounded-md border-2 border-slate-900 bg-slate-900 px-4 py-2 text-white transition hover:bg-white hover:text-slate-900 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-200"></div>
|
||||
<span class="px-3 text-sm text-slate-500">or</span>
|
||||
<div class="flex-1 border-t border-slate-200"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleGoogleLogin"
|
||||
:disabled="isSubmitting"
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-4 py-2 text-slate-700 transition hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
Sign in with Google
|
||||
</button>
|
||||
|
||||
<div v-if="loginError || error" class="mt-4 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{{ loginError || error }}
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-sm text-slate-600">
|
||||
Need an account?
|
||||
<NuxtLink
|
||||
to="/register"
|
||||
class="font-semibold text-slate-900 hover:text-slate-700"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
Create one instead
|
||||
</NuxtLink>
|
||||
</p>
|
||||
|
||||
<button
|
||||
@click="isOpen = false"
|
||||
class="mt-4 text-sm text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
151
app/components/designer/DesignerCanvas.vue
Normal file
151
app/components/designer/DesignerCanvas.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
import type { Canvas as FabricCanvas } from "fabric";
|
||||
|
||||
const props = defineProps<{
|
||||
size: number;
|
||||
registerCanvas: (payload: {
|
||||
canvas: FabricCanvas;
|
||||
fabric: typeof import("fabric");
|
||||
}) => void;
|
||||
unregisterCanvas: () => void;
|
||||
backgroundColor: string;
|
||||
}>();
|
||||
|
||||
const canvasElement = ref<HTMLCanvasElement | null>(null);
|
||||
const containerElement = ref<HTMLDivElement | null>(null);
|
||||
const isReady = ref(false);
|
||||
|
||||
let fabricCanvas: FabricCanvas | null = null;
|
||||
let fabricModule: typeof import("fabric") | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const syncCanvasDomStyles = () => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
const canvases = [fabricCanvas.lowerCanvasEl, fabricCanvas.upperCanvasEl];
|
||||
canvases.forEach((element) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.classList.add("rounded-full");
|
||||
element.style.borderRadius = "9999px";
|
||||
element.style.backgroundColor = "transparent";
|
||||
});
|
||||
};
|
||||
|
||||
const updateCssDimensions = (dimension?: number) => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
const targetSize =
|
||||
dimension ??
|
||||
containerElement.value?.clientWidth ??
|
||||
containerElement.value?.clientHeight ??
|
||||
null;
|
||||
if (!targetSize) {
|
||||
return;
|
||||
}
|
||||
fabricCanvas.setDimensions(
|
||||
{
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
},
|
||||
{ cssOnly: true }
|
||||
);
|
||||
fabricCanvas.calcOffset();
|
||||
fabricCanvas.requestRenderAll();
|
||||
syncCanvasDomStyles();
|
||||
};
|
||||
|
||||
const observeContainer = () => {
|
||||
if (!containerElement.value || !fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const dimension = Math.min(entry.contentRect.width, entry.contentRect.height);
|
||||
updateCssDimensions(dimension);
|
||||
});
|
||||
resizeObserver.observe(containerElement.value);
|
||||
updateCssDimensions();
|
||||
};
|
||||
|
||||
const setupCanvas = async () => {
|
||||
if (typeof window === "undefined" || !canvasElement.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
fabricModule = await import("fabric");
|
||||
|
||||
const { Canvas } = fabricModule;
|
||||
fabricCanvas = new Canvas(canvasElement.value, {
|
||||
backgroundColor: "transparent",
|
||||
selection: true,
|
||||
preserveObjectStacking: true,
|
||||
});
|
||||
|
||||
fabricCanvas.setDimensions({ width: props.size, height: props.size });
|
||||
|
||||
props.registerCanvas({ canvas: fabricCanvas, fabric: fabricModule });
|
||||
observeContainer();
|
||||
syncCanvasDomStyles();
|
||||
isReady.value = true;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setupCanvas();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
props.unregisterCanvas();
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
fabricCanvas = null;
|
||||
fabricModule = null;
|
||||
isReady.value = false;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.size,
|
||||
(next, prev) => {
|
||||
if (!isReady.value || next === prev || !fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
fabricCanvas.setDimensions({ width: next, height: next });
|
||||
updateCssDimensions();
|
||||
fabricCanvas.renderAll();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative mx-auto flex max-w-[min(720px,100%)] items-center justify-center overflow-hidden rounded-3xl border border-slate-700/60 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/40"
|
||||
>
|
||||
<div class="relative aspect-square w-full">
|
||||
<div class="absolute inset-4 sm:inset-5 md:inset-6 lg:inset-8">
|
||||
<div ref="containerElement" class="relative h-full w-full">
|
||||
<canvas
|
||||
ref="canvasElement"
|
||||
class="absolute inset-0 h-full w-full rounded-full"
|
||||
:width="size"
|
||||
:height="size"
|
||||
/>
|
||||
<div
|
||||
v-if="!isReady"
|
||||
class="absolute inset-0 grid place-items-center rounded-full bg-slate-900/70"
|
||||
>
|
||||
<span class="text-sm font-medium text-slate-400">Loading canvas…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
36
app/components/designer/DesignerPreview.vue
Normal file
36
app/components/designer/DesignerPreview.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
isCheckoutPending: boolean;
|
||||
checkoutPrice: number;
|
||||
checkoutError: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "checkout"): void;
|
||||
}>();
|
||||
|
||||
const handleCheckout = () => emit("checkout");
|
||||
|
||||
const priceLabel = computed(() => props.checkoutPrice.toFixed(2));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl border-2 border-emerald-600 bg-emerald-600 px-6 py-4 text-base font-semibold text-white transition hover:bg-white hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="props.isCheckoutPending"
|
||||
@click="handleCheckout"
|
||||
>
|
||||
{{ props.isCheckoutPending ? "Redirecting…" : `Buy This Design ($${priceLabel})` }}
|
||||
</button>
|
||||
<div
|
||||
v-if="props.checkoutError"
|
||||
class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700"
|
||||
>
|
||||
{{ props.checkoutError }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
242
app/components/designer/DesignerToolbar.vue
Normal file
242
app/components/designer/DesignerToolbar.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
onAddText: () => void;
|
||||
onAddCircle: () => void;
|
||||
onAddRectangle: () => void;
|
||||
onClear: () => void;
|
||||
onImportImage: (file: File) => Promise<void>;
|
||||
onFillChange: (fill: string) => void;
|
||||
onStrokeChange: (stroke: string) => void;
|
||||
onBackgroundChange: (background: string) => void;
|
||||
activeFill: string | null;
|
||||
activeStroke: string | null;
|
||||
activeBackground: string;
|
||||
canStyleSelection: boolean;
|
||||
zoom: number;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
onZoomChange: (zoom: number) => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onZoomReset: () => void;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "request-image"): void;
|
||||
}>();
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const fillValue = ref(props.activeFill ?? "#111827");
|
||||
const strokeValue = ref(props.activeStroke ?? "#3b82f6");
|
||||
const backgroundValue = ref(props.activeBackground ?? "#ffffff");
|
||||
const zoomSliderValue = ref(Math.round(props.zoom * 100));
|
||||
|
||||
watch(
|
||||
() => props.activeFill,
|
||||
(next) => {
|
||||
fillValue.value = next ?? "#111827";
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.activeStroke,
|
||||
(next) => {
|
||||
strokeValue.value = next ?? "#3b82f6";
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.activeBackground,
|
||||
(next) => {
|
||||
backgroundValue.value = next ?? "#ffffff";
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.zoom,
|
||||
(next) => {
|
||||
zoomSliderValue.value = Math.round(next * 100);
|
||||
}
|
||||
);
|
||||
|
||||
const openFilePicker = () => {
|
||||
if (!fileInput.value) {
|
||||
return;
|
||||
}
|
||||
fileInput.value.value = "";
|
||||
fileInput.value.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = input.files;
|
||||
if (!files || !files.length) {
|
||||
return;
|
||||
}
|
||||
const [file] = files;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
await props.onImportImage(file);
|
||||
};
|
||||
|
||||
const stylingDisabled = computed(() => !props.canStyleSelection);
|
||||
|
||||
const handleFillChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = input.value;
|
||||
fillValue.value = value;
|
||||
props.onFillChange(value);
|
||||
};
|
||||
|
||||
const handleStrokeChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = input.value;
|
||||
strokeValue.value = value;
|
||||
props.onStrokeChange(value);
|
||||
};
|
||||
|
||||
const handleBackgroundChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = input.value;
|
||||
backgroundValue.value = value;
|
||||
props.onBackgroundChange(value);
|
||||
};
|
||||
|
||||
const handleZoomInput = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = Number(input.value);
|
||||
if (Number.isNaN(value)) {
|
||||
return;
|
||||
}
|
||||
zoomSliderValue.value = value;
|
||||
props.onZoomChange(value / 100);
|
||||
};
|
||||
|
||||
const zoomLabel = computed(() => `${Math.round(props.zoom * 100)}%`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<!-- Add Elements Group -->
|
||||
<div class="flex items-center gap-1 border-r border-slate-200 pr-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-lg font-bold text-slate-700 transition hover:bg-slate-200"
|
||||
title="Add Text"
|
||||
@click="props.onAddText"
|
||||
>
|
||||
T
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-xl font-bold text-slate-700 transition hover:bg-slate-200"
|
||||
title="Add Circle"
|
||||
@click="props.onAddCircle"
|
||||
>
|
||||
●
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-xl font-bold text-slate-700 transition hover:bg-slate-200"
|
||||
title="Add Rectangle"
|
||||
@click="props.onAddRectangle"
|
||||
>
|
||||
▭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-xl font-bold text-slate-700 transition hover:bg-slate-200"
|
||||
title="Upload Image"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
🖼
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Color Pickers Group -->
|
||||
<div class="flex items-center gap-2 border-r border-slate-200 pr-3">
|
||||
<label class="flex items-center gap-1.5" title="Canvas Background">
|
||||
<span class="text-xs text-slate-600">Canvas</span>
|
||||
<input
|
||||
type="color"
|
||||
class="h-8 w-12 cursor-pointer rounded border border-slate-300 bg-white p-0.5"
|
||||
:value="backgroundValue"
|
||||
@input="handleBackgroundChange"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5" title="Fill Color">
|
||||
<span class="text-xs text-slate-600">Fill</span>
|
||||
<input
|
||||
type="color"
|
||||
class="h-8 w-12 cursor-pointer rounded border border-slate-300 bg-white p-0.5"
|
||||
:disabled="stylingDisabled"
|
||||
:value="fillValue"
|
||||
@input="handleFillChange"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5" title="Stroke Color">
|
||||
<span class="text-xs text-slate-600">Stroke</span>
|
||||
<input
|
||||
type="color"
|
||||
class="h-8 w-12 cursor-pointer rounded border border-slate-300 bg-white p-0.5"
|
||||
:disabled="stylingDisabled"
|
||||
:value="strokeValue"
|
||||
@input="handleStrokeChange"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Zoom Controls -->
|
||||
<div class="flex items-center gap-2 border-r border-slate-200 pr-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded border border-slate-300 bg-white text-sm font-bold text-slate-900 transition hover:bg-slate-50"
|
||||
title="Zoom Out"
|
||||
@click="props.onZoomOut"
|
||||
>
|
||||
–
|
||||
</button>
|
||||
<span class="min-w-12 text-center text-xs font-medium text-slate-700">
|
||||
{{ zoomLabel }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded border border-slate-300 bg-white text-sm font-bold text-slate-900 transition hover:bg-slate-50"
|
||||
title="Zoom In"
|
||||
@click="props.onZoomIn"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-slate-300 bg-white px-2 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
title="Reset Zoom"
|
||||
@click="props.onZoomReset"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Clear Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto rounded border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-medium text-rose-700 transition hover:bg-rose-100"
|
||||
@click="props.onClear"
|
||||
>
|
||||
Clear Canvas
|
||||
</button>
|
||||
|
||||
<!-- Hidden File Input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
class="hidden"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
68
app/components/designer/TemplatePicker.vue
Normal file
68
app/components/designer/TemplatePicker.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import type { SlipmatTemplate } from "~/composables/useSlipmatDesigner";
|
||||
|
||||
const props = defineProps<{
|
||||
templates: SlipmatTemplate[];
|
||||
selectedTemplateId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "select", templateId: string): void;
|
||||
}>();
|
||||
|
||||
const handleSelect = (templateId: string) => {
|
||||
emit("select", templateId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold text-slate-900">Jersey Template</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
Pick the size and print spec that matches this order.
|
||||
</p>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-1">
|
||||
<button
|
||||
v-for="template in props.templates"
|
||||
:key="template.id"
|
||||
type="button"
|
||||
class="group rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900"
|
||||
:class="{
|
||||
'border-slate-900 bg-slate-50 shadow-md':
|
||||
template.id === props.selectedTemplateId,
|
||||
}"
|
||||
@click="handleSelect(template.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base font-medium text-slate-900">
|
||||
{{ template.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="template.id === props.selectedTemplateId"
|
||||
class="rounded-full bg-slate-900 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-white"
|
||||
>
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<dl class="mt-3 grid grid-cols-4 gap-x-3 gap-y-2 text-xs text-slate-700">
|
||||
<div>
|
||||
<dt class="text-slate-500">Diameter</dt>
|
||||
<dd>{{ template.diameterInches }}"</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Resolution</dt>
|
||||
<dd>{{ template.dpi }} DPI</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Bleed</dt>
|
||||
<dd>{{ template.bleedInches ?? 0 }}"</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Safe Zone</dt>
|
||||
<dd>{{ template.safeZoneInches ?? 0 }}"</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
290
app/composables/useAuth.ts
Normal file
290
app/composables/useAuth.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
signInWithEmailAndPassword,
|
||||
signInWithPopup,
|
||||
GoogleAuthProvider,
|
||||
signOut as firebaseSignOut,
|
||||
onAuthStateChanged,
|
||||
getAuth,
|
||||
createUserWithEmailAndPassword,
|
||||
} from "firebase/auth";
|
||||
import { getApp, getApps, initializeApp } from "firebase/app";
|
||||
import type { User } from "firebase/auth";
|
||||
|
||||
export const useAuth = () => {
|
||||
const user = useState<User | null>("auth-user", () => null);
|
||||
const isLoading = useState<boolean>("auth-loading", () => true);
|
||||
const error = useState<string | null>("auth-error", () => null);
|
||||
const firebaseReady = useState<boolean>("firebase-ready", () => false);
|
||||
const listenerRegistered = useState<boolean>(
|
||||
"auth-listener-registered",
|
||||
() => false
|
||||
);
|
||||
const backendUser = useState<Record<string, any> | null>(
|
||||
"auth-backend-user",
|
||||
() => null
|
||||
);
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const ensureFirebaseApp = () => {
|
||||
if (!process.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!firebaseReady.value) {
|
||||
const firebaseConfig = {
|
||||
apiKey: config.public.firebaseApiKey,
|
||||
authDomain: config.public.firebaseAuthDomain,
|
||||
projectId: config.public.firebaseProjectId,
|
||||
storageBucket: config.public.firebaseStorageBucket,
|
||||
messagingSenderId: config.public.firebaseMessagingSenderId,
|
||||
appId: config.public.firebaseAppId,
|
||||
...(config.public.firebaseMeasurementId
|
||||
? { measurementId: config.public.firebaseMeasurementId }
|
||||
: {}),
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const getAuthInstance = () => {
|
||||
const app = ensureFirebaseApp();
|
||||
return app ? getAuth(app) : null;
|
||||
};
|
||||
|
||||
const authenticateWithBackend = async (idToken: string) => {
|
||||
try {
|
||||
const response = await $fetch<{
|
||||
token?: string;
|
||||
user?: Record<string, any> | 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<string>) => {
|
||||
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();
|
||||
if (!auth) {
|
||||
throw new Error("Firebase not initialized");
|
||||
}
|
||||
|
||||
const userCredential = await signInWithEmailAndPassword(
|
||||
auth,
|
||||
email,
|
||||
password
|
||||
);
|
||||
const idToken = await userCredential.user.getIdToken();
|
||||
|
||||
try {
|
||||
await authenticateWithBackend(idToken);
|
||||
} catch (backendErr) {
|
||||
console.warn(
|
||||
"[useAuth] Backend authentication failed after email login:",
|
||||
backendErr
|
||||
);
|
||||
}
|
||||
|
||||
user.value = userCredential.user;
|
||||
registerListener();
|
||||
|
||||
return userCredential.user;
|
||||
} catch (err: any) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
} finally {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
if (!auth) {
|
||||
throw new Error("Firebase not initialized");
|
||||
}
|
||||
|
||||
await firebaseSignOut(auth);
|
||||
user.value = null;
|
||||
backendUser.value = null;
|
||||
} catch (err: any) {
|
||||
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();
|
||||
};
|
||||
|
||||
return {
|
||||
user: readonly(user),
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
backendUser: readonly(backendUser),
|
||||
initAuth,
|
||||
signInWithEmail,
|
||||
signInWithGoogle,
|
||||
registerWithEmail,
|
||||
signOut,
|
||||
getIdToken,
|
||||
};
|
||||
};
|
||||
1
app/composables/useFirebaseStorage.ts
Normal file
1
app/composables/useFirebaseStorage.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
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
app/composables/useSlipmatDesigner.ts
Normal file
5
app/composables/useSlipmatDesigner.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { computed, ref, shallowRef, watch } from "vue";
|
||||
|
||||
// import type { fabric as FabricNamespace } from "fabric";
|
||||
|
||||
export * from "../../composables/useSlipmatDesigner";
|
||||
235
app/pages/checkout/success.vue
Normal file
235
app/pages/checkout/success.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const sessionId = computed(() => {
|
||||
const raw = route.query.session_id
|
||||
if (Array.isArray(raw)) {
|
||||
return raw[0]
|
||||
}
|
||||
return typeof raw === 'string' ? raw : null
|
||||
})
|
||||
|
||||
const { data: session, pending, error } = useAsyncData('checkout-session', async () => {
|
||||
if (!sessionId.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await $fetch(`/api/checkout/${sessionId.value}`)
|
||||
}, {
|
||||
watch: [sessionId],
|
||||
})
|
||||
|
||||
const amountLabel = computed(() => {
|
||||
if (!session.value?.amountTotal) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currencyCode = session.value.currency?.toUpperCase() ?? 'USD'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
}).format(session.value.amountTotal / 100)
|
||||
})
|
||||
|
||||
const copyStatus = ref<'idle' | 'copied' | 'error'>('idle')
|
||||
const supportsClipboard = ref(false)
|
||||
const transactionStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const transactionMessage = ref<string | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
supportsClipboard.value = typeof navigator !== 'undefined' && !!navigator.clipboard
|
||||
})
|
||||
|
||||
watch(
|
||||
() => session.value,
|
||||
async (sessionValue) => {
|
||||
if (!process.client) {
|
||||
return
|
||||
}
|
||||
if (transactionStatus.value !== 'idle') {
|
||||
return
|
||||
}
|
||||
if (!sessionId.value || !sessionValue?.id) {
|
||||
return
|
||||
}
|
||||
if (sessionValue.paymentStatus !== 'paid') {
|
||||
return
|
||||
}
|
||||
|
||||
transactionStatus.value = 'saving'
|
||||
|
||||
try {
|
||||
await $fetch('/api/transactions', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
stripeSessionId: sessionValue.id,
|
||||
designId: sessionValue.metadata?.designId ?? `unknown-${sessionValue.id}`,
|
||||
templateId: sessionValue.metadata?.templateId ?? null,
|
||||
amount: sessionValue.amountTotal
|
||||
? (sessionValue.amountTotal / 100).toFixed(2)
|
||||
: "0",
|
||||
currency: sessionValue.currency ?? 'usd',
|
||||
customerEmail: sessionValue.customerEmail ?? null,
|
||||
customerDetails: sessionValue.customerDetails,
|
||||
},
|
||||
})
|
||||
transactionStatus.value = 'saved'
|
||||
} catch (err: any) {
|
||||
console.error('Failed to persist transaction', err)
|
||||
transactionStatus.value = 'error'
|
||||
transactionMessage.value = err?.message ?? 'Unable to record this transaction.'
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const copySessionId = async () => {
|
||||
if (!supportsClipboard.value || !sessionId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(sessionId.value)
|
||||
copyStatus.value = 'copied'
|
||||
setTimeout(() => {
|
||||
copyStatus.value = 'idle'
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy session id', err)
|
||||
copyStatus.value = 'error'
|
||||
setTimeout(() => {
|
||||
copyStatus.value = 'idle'
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const goToDesigner = () => {
|
||||
const metadata = (session.value?.metadata ?? {}) as Record<string, unknown>
|
||||
const designId = typeof metadata.designId === 'string' ? metadata.designId : null
|
||||
|
||||
if (designId) {
|
||||
router.push({ path: '/', query: { designId } })
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-white pb-16">
|
||||
<AppNavbar />
|
||||
|
||||
<section class="mx-auto flex max-w-2xl flex-col gap-6 px-4 pt-16 text-slate-900">
|
||||
<header class="space-y-4 text-center">
|
||||
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
|
||||
<svg viewBox="0 0 24 24" class="h-10 w-10 fill-current">
|
||||
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm4.7 7.3-5.2 5.2a1 1 0 0 1-1.4 0l-2-2a1 1 0 0 1 1.4-1.4l1.3 1.29 4.5-4.49a1 1 0 0 1 1.4 1.4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold text-slate-900">Payment Confirmed</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
Thank you for purchasing your custom jersey design. We've received your payment and sent a confirmation email.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="!sessionId" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<p class="text-sm text-slate-700">
|
||||
We couldn't find a Stripe session ID in the URL. Please return to the designer and try again.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 w-full rounded-xl border-2 border-slate-900 bg-slate-900 px-4 py-3 text-sm font-medium text-white transition hover:bg-white hover:text-slate-900"
|
||||
@click="goToDesigner"
|
||||
>
|
||||
Back to Designer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-6"
|
||||
>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Order Summary</h2>
|
||||
<dl class="mt-4 space-y-2 text-sm text-slate-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<dt>Total Paid</dt>
|
||||
<dd>
|
||||
<span v-if="pending" class="text-slate-500">Loading...</span>
|
||||
<span v-else-if="amountLabel" class="font-semibold text-slate-900">{{ amountLabel }}</span>
|
||||
<span v-else class="text-slate-500">Pending</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<dt>Payment Status</dt>
|
||||
<dd>
|
||||
<span v-if="pending" class="text-slate-500">Loading...</span>
|
||||
<span v-else-if="session?.paymentStatus" class="font-semibold text-slate-900">
|
||||
{{ session.paymentStatus === 'paid' ? 'Paid' : session.paymentStatus }}
|
||||
</span>
|
||||
<span v-else class="text-slate-500">Unknown</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="session?.customerEmail" class="flex items-center justify-between">
|
||||
<dt>Receipt sent to</dt>
|
||||
<dd class="font-semibold text-slate-900">{{ session.customerEmail }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<dt>Session ID</dt>
|
||||
<dd class="flex items-center gap-2 text-xs text-slate-600">
|
||||
<span class="truncate max-w-[180px] sm:max-w-xs" :title="sessionId">{{ sessionId }}</span>
|
||||
<button
|
||||
v-if="supportsClipboard"
|
||||
type="button"
|
||||
class="rounded-md border border-slate-300 px-2 py-1 text-[11px] uppercase tracking-wide text-slate-700 transition hover:border-slate-900 hover:text-slate-900"
|
||||
@click="copySessionId"
|
||||
>
|
||||
{{ copyStatus === 'copied' ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div v-if="transactionStatus === 'saving'" class="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
|
||||
Recording your transaction...
|
||||
</div>
|
||||
<div v-else-if="transactionStatus === 'error'" class="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{{ transactionMessage || 'We could not record this transaction. Please contact support with your session ID.' }}
|
||||
</div>
|
||||
<div v-else-if="transactionStatus === 'saved'" class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||
Transaction stored safely.
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{{ error.message || 'We couldn\'t load the session details. Please contact support if this persists.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-slate-900">What's next?</h2>
|
||||
<ul class="mt-4 space-y-3 text-sm text-slate-700">
|
||||
<li>
|
||||
We'll email you a confirmation that includes your payment details and a link to download the production-ready files once they're generated.
|
||||
</li>
|
||||
<li>
|
||||
Need to tweak the design? Use the button below to reopen this project; your saved layout will load automatically.
|
||||
</li>
|
||||
<li>
|
||||
Have questions? Reply directly to the confirmation email and our team will help out.
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-6 w-full rounded-xl border-2 border-emerald-600 bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-white hover:text-emerald-600"
|
||||
@click="goToDesigner"
|
||||
>
|
||||
Back to Designer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
441
app/pages/designer.vue
Normal file
441
app/pages/designer.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<script setup lang="ts">
|
||||
import DesignerCanvas from "~/components/designer/DesignerCanvas.vue";
|
||||
import DesignerPreview from "~/components/designer/DesignerPreview.vue";
|
||||
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,
|
||||
selectedTemplate,
|
||||
selectTemplate,
|
||||
loadDesign,
|
||||
displaySize,
|
||||
templateLabel,
|
||||
productionPixelSize,
|
||||
previewUrl,
|
||||
registerCanvas,
|
||||
unregisterCanvas,
|
||||
addTextbox,
|
||||
addShape,
|
||||
addImageFromFile,
|
||||
clearDesign,
|
||||
downloadPreview,
|
||||
downloadProduction,
|
||||
exportDesign,
|
||||
isExporting,
|
||||
activeFillColor,
|
||||
activeStrokeColor,
|
||||
canStyleSelection,
|
||||
setActiveFillColor,
|
||||
setActiveStrokeColor,
|
||||
setBackgroundColor,
|
||||
zoomLevel,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
setZoom,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
} = useSlipmatDesigner();
|
||||
|
||||
const DESIGN_PRICE_USD = 39.99;
|
||||
|
||||
const { user, backendUser, initAuth, isLoading } = useAuth();
|
||||
const loginModal = useLoginModal();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const activeDesignId = ref<string | null>(
|
||||
typeof route.query.designId === "string" ? route.query.designId : null
|
||||
);
|
||||
const loadedDesignId = ref<string | null>(null);
|
||||
const isDesignLoading = ref(false);
|
||||
const isCheckoutPending = ref(false);
|
||||
const checkoutError = ref<string | null>(null);
|
||||
const lastExportedDesign = ref<ExportedDesign | null>(null);
|
||||
|
||||
type LoadDesignInput = Parameters<typeof loadDesign>[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<StorageUploadResponse> => {
|
||||
if (!process.client) {
|
||||
throw new Error("Asset uploads can only run in the browser context.");
|
||||
}
|
||||
|
||||
const backendUrl = config.public.backendUrl;
|
||||
if (!backendUrl) {
|
||||
throw new Error("Backend 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<StorageUploadResponse>("/storage/upload", {
|
||||
baseURL: backendUrl,
|
||||
method: "POST",
|
||||
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 }
|
||||
),
|
||||
]);
|
||||
|
||||
// Get Firebase ID token for authentication
|
||||
const idToken = user.value ? await user.value.getIdToken() : null;
|
||||
|
||||
await $fetch("/api/designs", {
|
||||
method: "POST",
|
||||
headers: idToken ? { Authorization: `Bearer ${idToken}` } : {},
|
||||
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);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const result = await exportDesign();
|
||||
if (result) {
|
||||
lastExportedDesign.value = result;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (!process.client) {
|
||||
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;
|
||||
}
|
||||
|
||||
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 =
|
||||
activeDesignId.value ??
|
||||
(typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `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 ?? undefined,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-white pb-16 text-slate-900">
|
||||
<AppNavbar />
|
||||
<div class="mx-auto max-w-6xl px-4 pt-12 sm:px-6 lg:px-8">
|
||||
<header class="space-y-3">
|
||||
<p class="text-sm uppercase tracking-[0.35em] text-slate-600">
|
||||
TableJerseys Designer
|
||||
</p>
|
||||
<h1 class="text-3xl font-bold text-slate-900 sm:text-4xl">
|
||||
Craft custom jerseys ready for your table.
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<section class="mt-10 flex flex-col gap-8 lg:grid lg:grid-cols-[320px_minmax(0,1fr)] lg:gap-6">
|
||||
<!-- Left Sidebar - Template Picker and Preview (together on desktop, separate on mobile) -->
|
||||
<div class="contents lg:block lg:space-y-6">
|
||||
<div class="order-1">
|
||||
<TemplatePicker
|
||||
:templates="templates"
|
||||
:selected-template-id="selectedTemplate.id"
|
||||
@select="handleTemplateSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="order-3">
|
||||
<DesignerPreview
|
||||
:is-checkout-pending="isCheckoutPending"
|
||||
:checkout-price="DESIGN_PRICE_USD"
|
||||
:checkout-error="checkoutError"
|
||||
@checkout="handleCheckout"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Designer Canvas - Second on mobile, right column on desktop -->
|
||||
<div class="order-2 flex flex-col gap-6 lg:order-0">
|
||||
<div
|
||||
class="rounded-3xl border border-slate-200 bg-white shadow-xl"
|
||||
>
|
||||
<DesignerToolbar
|
||||
:on-add-text="addTextbox"
|
||||
:on-add-circle="() => addShape('circle')"
|
||||
:on-add-rectangle="() => addShape('rect')"
|
||||
:on-clear="clearDesign"
|
||||
:on-import-image="addImageFromFile"
|
||||
:on-fill-change="setActiveFillColor"
|
||||
:on-stroke-change="setActiveStrokeColor"
|
||||
:on-background-change="setBackgroundColor"
|
||||
:active-fill="activeFillColor"
|
||||
:active-stroke="activeStrokeColor"
|
||||
:active-background="selectedTemplate.backgroundColor"
|
||||
:can-style-selection="canStyleSelection"
|
||||
:zoom="zoomLevel"
|
||||
:min-zoom="minZoom"
|
||||
:max-zoom="maxZoom"
|
||||
:on-zoom-change="setZoom"
|
||||
:on-zoom-in="zoomIn"
|
||||
:on-zoom-out="zoomOut"
|
||||
:on-zoom-reset="resetZoom"
|
||||
/>
|
||||
<div class="p-6">
|
||||
<DesignerCanvas
|
||||
:size="displaySize"
|
||||
:background-color="selectedTemplate.backgroundColor"
|
||||
:register-canvas="registerCanvas"
|
||||
:unregister-canvas="unregisterCanvas"
|
||||
/>
|
||||
<p class="mt-4 text-sm text-slate-600">
|
||||
Safe zone and bleed guides update automatically when you switch
|
||||
templates. Use the toolbar to layer text, shapes, and imagery
|
||||
inside the design area.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
148
app/pages/index.vue
Normal file
148
app/pages/index.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
const router = useRouter();
|
||||
|
||||
const startDesigning = () => {
|
||||
router.push('/designer');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative min-h-screen overflow-hidden bg-white">
|
||||
<!-- Subtle Background Pattern -->
|
||||
<div class="absolute inset-0 opacity-5">
|
||||
<div class="absolute inset-0" style="background-image: radial-gradient(circle at 1px 1px, rgb(156 163 175) 1px, transparent 0); background-size: 40px 40px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="relative z-20 flex min-h-screen flex-col px-4 py-12 md:px-8 lg:px-16">
|
||||
<!-- Top Section - Split Layout -->
|
||||
<div class="flex flex-1 flex-col items-center justify-between gap-8 lg:flex-row lg:gap-16">
|
||||
<!-- Left Side - Title & Description -->
|
||||
<div class="w-full space-y-8 text-center lg:w-1/2 lg:text-left">
|
||||
<h1 class="text-6xl font-bold tracking-tight text-slate-900 sm:text-7xl md:text-8xl">
|
||||
TableJerseys
|
||||
</h1>
|
||||
<div class="space-y-4">
|
||||
<p class="text-2xl font-semibold text-slate-700 sm:text-3xl md:text-4xl">
|
||||
Design custom jerseys for your table
|
||||
</p>
|
||||
|
||||
<!-- Simple preview for mobile view -->
|
||||
<div class="relative mx-auto my-8 block lg:hidden">
|
||||
<div class="relative mx-auto h-[280px] w-[280px] sm:h-80 sm:w-80">
|
||||
<div class="flex h-full w-full items-center justify-center rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-slate-50 to-slate-100 shadow-lg overflow-hidden">
|
||||
<!-- Rotating Table with Fitted Cover -->
|
||||
<div class="animate-spin-slow relative" style="transform-style: preserve-3d;">
|
||||
<div class="relative">
|
||||
<!-- Table with fitted cover -->
|
||||
<div class="relative">
|
||||
<!-- Table Top -->
|
||||
<div class="relative h-24 w-40 rounded-t-sm bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900 shadow-xl">
|
||||
<!-- Jersey design on top of cover -->
|
||||
<div class="absolute inset-4 flex items-center justify-center rounded-lg bg-gradient-to-br from-blue-400 via-white to-red-400 shadow-lg border-2 border-slate-300">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-slate-800">23</div>
|
||||
<div class="text-[7px] font-semibold text-slate-600 tracking-wider">CUSTOM</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fitted Cover Skirt (draping down) -->
|
||||
<div class="absolute top-24 left-0 right-0 h-16 bg-gradient-to-b from-slate-800 via-slate-900 to-slate-950 rounded-b-sm shadow-2xl">
|
||||
<!-- Fabric folds/creases -->
|
||||
<div class="absolute inset-0 opacity-30" style="background: repeating-linear-gradient(90deg, transparent, transparent 8px, rgba(0,0,0,0.3) 8px, rgba(0,0,0,0.3) 9px);"></div>
|
||||
<!-- Shadow at bottom -->
|
||||
<div class="absolute bottom-0 left-0 right-0 h-2 bg-black/40"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-base text-slate-600 sm:text-lg">
|
||||
Create professional, print-ready jersey designs in minutes
|
||||
</p>
|
||||
<p class="text-sm text-slate-500 sm:text-base">
|
||||
Perfect for sports teams, events, and table enthusiasts.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<NuxtLink
|
||||
to="/designer"
|
||||
class="group relative inline-flex overflow-hidden rounded-lg border-2 border-slate-900 bg-slate-900 px-12 py-5 text-xl font-bold text-white shadow-lg transition-all hover:bg-white hover:text-slate-900 active:scale-95"
|
||||
>
|
||||
<span class="relative z-10 flex items-center gap-3">
|
||||
Start Designing
|
||||
<svg class="h-6 w-6 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Stats Section - Now on Left Side -->
|
||||
<div class="mx-auto grid max-w-4xl gap-8 pt-12 grid-cols-3 sm:grid-cols-3 lg:mx-0">
|
||||
<div class="space-y-2 text-center lg:text-left">
|
||||
<div class="text-3xl font-bold text-slate-900 md:text-4xl">Custom</div>
|
||||
<div class="text-sm text-slate-600">Any Size</div>
|
||||
</div>
|
||||
<div class="space-y-2 text-center lg:text-left">
|
||||
<div class="text-3xl font-bold text-slate-900 md:text-4xl">300 DPI</div>
|
||||
<div class="text-sm text-slate-600">Print Quality</div>
|
||||
</div>
|
||||
<div class="space-y-2 text-center lg:text-left">
|
||||
<div class="text-3xl font-bold text-slate-900 md:text-4xl">$39.99</div>
|
||||
<div class="text-sm text-slate-600">Per Design</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side - Simple Image -->
|
||||
<div class="relative hidden w-full lg:block lg:w-1/2">
|
||||
<div class="relative mx-auto max-w-4xl">
|
||||
<div class="relative mx-auto h-[300px] w-[300px] sm:h-[600px] sm:w-[600px]">
|
||||
<div class="flex h-full w-full items-center justify-center rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-slate-50 to-slate-100 shadow-xl overflow-hidden">
|
||||
<!-- Rotating Table with Fitted Cover -->
|
||||
<div class=" relative" style="transform-style: preserve-3d;">
|
||||
<div class="relative">
|
||||
<!-- Table with fitted cover -->
|
||||
<div class="relative">
|
||||
<!-- Table Top with jersey on cover -->
|
||||
<div class="relative h-48 w-96 sm:h-64 sm:w-[500px] rounded-t-lg bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900 shadow-2xl">
|
||||
<!-- Jersey design on top of cover -->
|
||||
<div class="absolute inset-8 sm:inset-12 flex items-center justify-center rounded-xl bg-gradient-to-br from-blue-400 via-white to-red-400 shadow-2xl border-4 border-slate-300">
|
||||
<div class="absolute inset-4 sm:inset-6 flex items-center justify-center rounded-lg bg-gradient-to-br from-blue-100 via-white to-red-100 border-2 border-slate-200">
|
||||
<div class="text-center">
|
||||
<div class="text-5xl sm:text-7xl font-bold text-slate-800">23</div>
|
||||
<div class="text-sm sm:text-xl font-semibold text-slate-600 tracking-wider">CUSTOM</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Subtle highlights on cover top -->
|
||||
<div class="absolute top-4 left-8 h-6 w-12 rounded-full bg-white/10 blur-lg"></div>
|
||||
</div>
|
||||
|
||||
<!-- Fitted Cover Skirt (draping down like trade show table) -->
|
||||
<div class="absolute top-48 sm:top-64 left-0 right-0 h-32 sm:h-40 bg-gradient-to-b from-slate-800 via-slate-900 to-slate-950 rounded-b-lg shadow-2xl">
|
||||
<!-- Fabric folds/pleats -->
|
||||
<div class="absolute inset-0 opacity-40" style="background: repeating-linear-gradient(90deg, transparent, transparent 16px, rgba(0,0,0,0.4) 16px, rgba(0,0,0,0.4) 18px);"></div>
|
||||
<!-- More realistic vertical folds -->
|
||||
<div class="absolute inset-0 opacity-20" style="background: repeating-linear-gradient(90deg, rgba(255,255,255,0.05) 0px, transparent 2px, transparent 14px, rgba(0,0,0,0.3) 16px);"></div>
|
||||
<!-- Shadow at bottom edge -->
|
||||
<div class="absolute bottom-0 left-0 right-0 h-4 bg-black/50 rounded-b-lg"></div>
|
||||
<!-- Ground shadow -->
|
||||
<div class="absolute -bottom-2 left-4 right-4 h-2 bg-black/30 blur-sm rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
227
app/pages/orders.vue
Normal file
227
app/pages/orders.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
interface OrderRecord {
|
||||
id?: string;
|
||||
designId?: string;
|
||||
templateId?: string | null;
|
||||
amount?: string | number;
|
||||
currency?: string;
|
||||
customerEmail?: string | null;
|
||||
status?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
stripeSessionId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const loginModal = useLoginModal();
|
||||
const { user, backendUser, initAuth, isLoading } = useAuth();
|
||||
|
||||
const customerEmail = computed(() => backendUser.value?.email || user.value?.email || null);
|
||||
|
||||
const orders = ref<OrderRecord[]>([]);
|
||||
const ordersLoading = ref(false);
|
||||
const ordersError = ref<string | null>(null);
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(user.value && customerEmail.value));
|
||||
|
||||
onMounted(() => {
|
||||
initAuth();
|
||||
});
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customerEmail.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
ordersLoading.value = true;
|
||||
ordersError.value = null;
|
||||
|
||||
try {
|
||||
const response = await $fetch<OrderRecord[]>("/api/orders", {
|
||||
query: { customerEmail: customerEmail.value ?? undefined },
|
||||
});
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
orders.value = response;
|
||||
} else if (response && typeof response === "object" && "orders" in response) {
|
||||
orders.value = (response as any).orders ?? [];
|
||||
} else {
|
||||
orders.value = [];
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load order history", error);
|
||||
ordersError.value = error?.message ?? "Unable to load orders";
|
||||
orders.value = [];
|
||||
} finally {
|
||||
ordersLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => customerEmail.value,
|
||||
(email) => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
if (email) {
|
||||
fetchOrders();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const formatAmount = (record: OrderRecord) => {
|
||||
const rawAmount = record.amount;
|
||||
const amountString =
|
||||
typeof rawAmount === "string"
|
||||
? rawAmount
|
||||
: typeof rawAmount === "number"
|
||||
? rawAmount.toString()
|
||||
: "0";
|
||||
|
||||
const numericAmount = Number.parseFloat(amountString);
|
||||
const currencyCode = (record.currency ?? "USD").toUpperCase();
|
||||
|
||||
if (Number.isFinite(numericAmount)) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currencyCode,
|
||||
}).format(numericAmount);
|
||||
}
|
||||
|
||||
return `${amountString} ${currencyCode}`.trim();
|
||||
};
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return "—";
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
};
|
||||
|
||||
const openLogin = () => {
|
||||
loginModal.value = true;
|
||||
router.push("/");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-white pb-16 text-slate-900">
|
||||
<AppNavbar />
|
||||
|
||||
<section class="mx-auto flex max-w-4xl flex-col gap-8 px-4 pt-16">
|
||||
<header class="space-y-3">
|
||||
<p class="text-sm uppercase tracking-[0.35em] text-slate-600">Account</p>
|
||||
<h1 class="text-3xl font-semibold text-slate-900">Order history</h1>
|
||||
<p class="text-sm text-slate-600">
|
||||
Review your completed purchases and keep track of the designs linked to each order.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div v-if="isLoading" class="grid gap-4">
|
||||
<div class="h-28 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
||||
<div class="h-28 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isAuthenticated" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Sign in to view orders</h2>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
Orders are associated with your TableJerseys account. Sign in to see the designs you've purchased.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 rounded-md border-2 border-slate-900 bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white hover:text-slate-900"
|
||||
@click="openLogin"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="text-sm text-slate-600">
|
||||
Signed in as <span class="font-medium text-slate-900">{{ customerEmail }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-700 transition hover:border-slate-900 hover:text-slate-900"
|
||||
@click="fetchOrders"
|
||||
:disabled="ordersLoading"
|
||||
>
|
||||
{{ ordersLoading ? 'Refreshing…' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ordersError" class="rounded-2xl border border-rose-200 bg-rose-50 p-6 text-sm text-rose-700">
|
||||
{{ ordersError }}
|
||||
</div>
|
||||
|
||||
<div v-if="ordersLoading" class="grid gap-4">
|
||||
<div class="h-24 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
||||
<div class="h-24 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
||||
<div class="h-24 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="orders.length === 0" class="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-700 shadow-sm">
|
||||
No orders yet. When you complete a purchase, your history will show up here for easy reference.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<article
|
||||
v-for="order in orders"
|
||||
:key="order.id || `${order.designId}-${order.createdAt}`"
|
||||
class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">
|
||||
{{ formatAmount(order) }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-600">
|
||||
{{ order.currency ? order.currency.toUpperCase() : 'USD' }} • {{ formatDate(order.createdAt || order.updatedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="order.status" class="rounded-full border border-slate-300 bg-slate-50 px-3 py-1 text-xs uppercase tracking-[0.25em] text-slate-700">
|
||||
{{ order.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="mt-4 grid gap-3 text-sm text-slate-700 sm:grid-cols-2">
|
||||
<div v-if="order.designId">
|
||||
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">Design ID</dt>
|
||||
<dd class="text-slate-900 break-all">{{ order.designId }}</dd>
|
||||
</div>
|
||||
<div v-if="order.templateId">
|
||||
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">Template</dt>
|
||||
<dd class="text-slate-900">{{ order.templateId }}</dd>
|
||||
</div>
|
||||
<div v-if="order.customerEmail">
|
||||
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">Receipt sent to</dt>
|
||||
<dd class="text-slate-900">{{ order.customerEmail }}</dd>
|
||||
</div>
|
||||
<div v-if="order.stripeSessionId">
|
||||
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">Stripe session</dt>
|
||||
<dd class="text-slate-900 break-all">{{ order.stripeSessionId }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<NuxtLink
|
||||
v-if="order.designId"
|
||||
:to="{ path: '/', query: { designId: order.designId } }"
|
||||
class="mt-4 inline-flex items-center gap-2 text-sm font-semibold text-slate-900 transition hover:text-slate-700"
|
||||
>
|
||||
Reopen design
|
||||
<span aria-hidden="true">→</span>
|
||||
</NuxtLink>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
156
app/pages/profile.vue
Normal file
156
app/pages/profile.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
const router = useRouter();
|
||||
const loginModal = useLoginModal();
|
||||
const { user, backendUser, initAuth, isLoading, signOut } = useAuth();
|
||||
|
||||
onMounted(() => {
|
||||
initAuth();
|
||||
});
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(user.value));
|
||||
|
||||
const displayName = computed(() => {
|
||||
return (
|
||||
backendUser.value?.name ||
|
||||
user.value?.displayName ||
|
||||
backendUser.value?.email ||
|
||||
user.value?.email ||
|
||||
"Slipmat Creator"
|
||||
);
|
||||
});
|
||||
|
||||
const displayEmail = computed(() => backendUser.value?.email || user.value?.email || "Unknown");
|
||||
|
||||
const displayId = computed(() => backendUser.value?.id || user.value?.uid || null);
|
||||
|
||||
const lastLogin = computed(() => {
|
||||
const raw =
|
||||
backendUser.value?.lastLogin ||
|
||||
backendUser.value?.updatedAt ||
|
||||
user.value?.metadata?.lastSignInTime ||
|
||||
user.value?.metadata?.creationTime ||
|
||||
null;
|
||||
|
||||
return raw ? new Date(raw) : null;
|
||||
});
|
||||
|
||||
const profileFields = computed(() => {
|
||||
const entries: Array<{ label: string; value: string | null }> = [
|
||||
{ label: "Display name", value: displayName.value },
|
||||
{ label: "Email", value: displayEmail.value },
|
||||
];
|
||||
|
||||
if (displayId.value) {
|
||||
entries.push({ label: "User ID", value: displayId.value });
|
||||
}
|
||||
|
||||
if (lastLogin.value) {
|
||||
entries.push({ label: "Last login", value: lastLogin.value.toLocaleString() });
|
||||
}
|
||||
|
||||
if (backendUser.value?.role) {
|
||||
entries.push({ label: "Role", value: String(backendUser.value.role) });
|
||||
}
|
||||
|
||||
if (backendUser.value?.createdAt) {
|
||||
entries.push({ label: "Created", value: new Date(backendUser.value.createdAt).toLocaleString() });
|
||||
}
|
||||
|
||||
return entries;
|
||||
});
|
||||
|
||||
const openLogin = () => {
|
||||
loginModal.value = true;
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
router.push("/");
|
||||
} catch (error) {
|
||||
console.error("Sign out failed", error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-white pb-16 text-slate-900">
|
||||
<AppNavbar />
|
||||
|
||||
<section class="mx-auto flex max-w-3xl flex-col gap-8 px-4 pt-16">
|
||||
<header class="space-y-3">
|
||||
<p class="text-sm uppercase tracking-[0.35em] text-slate-600">Account</p>
|
||||
<h1 class="text-3xl font-semibold text-slate-900">Profile</h1>
|
||||
<p class="text-sm text-slate-600">
|
||||
View your TableJerseys account details and manage sessions. Changes to your profile are controlled through your authentication provider.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div v-if="isLoading" class="grid gap-4">
|
||||
<div class="h-28 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
||||
<div class="h-28 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isAuthenticated" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-xl font-semibold text-slate-900">You're signed out</h2>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
Sign in to view your profile information and order history.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 rounded-md border-2 border-slate-900 bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white hover:text-slate-900"
|
||||
@click="openLogin"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ displayName }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ displayEmail }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<NuxtLink
|
||||
to="/orders"
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-slate-900 hover:text-slate-900"
|
||||
>
|
||||
View order history
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-rose-300 px-4 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-50"
|
||||
@click="handleSignOut"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Account details</h3>
|
||||
<dl class="mt-4 grid gap-4 text-sm text-slate-700 sm:grid-cols-2">
|
||||
<div v-for="field in profileFields" :key="field.label" class="space-y-1">
|
||||
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">{{ field.label }}</dt>
|
||||
<dd class="text-sm text-slate-900 break-all">{{ field.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div v-if="backendUser" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Backend session</h3>
|
||||
<p class="text-sm text-slate-600">
|
||||
The following data is provided by the TableJerseys backend and may include additional metadata used for order fulfillment.
|
||||
</p>
|
||||
<pre class="mt-4 overflow-x-auto rounded-xl bg-slate-50 p-4 text-xs text-slate-700">
|
||||
{{ JSON.stringify(backendUser, null, 2) }}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
186
app/pages/register.vue
Normal file
186
app/pages/register.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
const router = useRouter();
|
||||
const { user, registerWithEmail, signInWithGoogle, isLoading, error } = useAuth();
|
||||
const loginModal = useLoginModal();
|
||||
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const localError = ref<string | null>(null);
|
||||
|
||||
const isProcessing = computed(() => isSubmitting.value || isLoading.value);
|
||||
const combinedError = computed(() => localError.value || error.value || null);
|
||||
|
||||
const redirectIfAuthenticated = (maybeUser: unknown) => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
if (maybeUser) {
|
||||
router.replace("/");
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => user.value,
|
||||
(currentUser) => {
|
||||
if (currentUser) {
|
||||
redirectIfAuthenticated(currentUser);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
localError.value = null;
|
||||
|
||||
if (!email.value.trim()) {
|
||||
localError.value = "Email is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.value.length < 6) {
|
||||
localError.value = "Password must be at least 6 characters.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
localError.value = "Passwords do not match.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isSubmitting.value = true;
|
||||
await registerWithEmail(email.value.trim(), password.value);
|
||||
await router.replace("/");
|
||||
} catch (err: any) {
|
||||
localError.value = err?.message ?? "Registration failed. Please try again.";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleRegister = async () => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isSubmitting.value = true;
|
||||
await signInWithGoogle();
|
||||
await router.replace("/");
|
||||
} catch (err: any) {
|
||||
localError.value = err?.message ?? "Google sign-in failed. Please try again.";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goToSignIn = async () => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
loginModal.value = true;
|
||||
await router.push("/");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-white pb-16 text-slate-900">
|
||||
<AppNavbar />
|
||||
|
||||
<section class="mx-auto flex max-w-md flex-col gap-8 px-4 pt-16">
|
||||
<header class="space-y-3 text-center">
|
||||
<p class="text-sm uppercase tracking-[0.35em] text-slate-600">Create Account</p>
|
||||
<h1 class="text-3xl font-semibold text-slate-900">Join TableJerseys</h1>
|
||||
<p class="text-sm text-slate-600">
|
||||
Sign up with email and password to save your designs and return to them anytime.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm" @submit.prevent="handleRegister">
|
||||
<div class="space-y-1">
|
||||
<label for="email" class="block text-sm font-medium text-slate-700">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="password" class="block text-sm font-medium text-slate-700">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
minlength="6"
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
|
||||
/>
|
||||
<p class="text-xs text-slate-500">Minimum 6 characters.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="confirm-password" class="block text-sm font-medium text-slate-700">Confirm Password</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
minlength="6"
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isProcessing"
|
||||
class="w-full rounded-md border-2 border-slate-900 bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white hover:text-slate-900 disabled:opacity-60"
|
||||
>
|
||||
{{ isProcessing ? "Creating account..." : "Create account" }}
|
||||
</button>
|
||||
|
||||
<div class="my-2 flex items-center gap-3 text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||
<div class="flex-1 border-t border-slate-200"></div>
|
||||
<span>or</span>
|
||||
<div class="flex-1 border-t border-slate-200"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isProcessing"
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:opacity-60"
|
||||
@click="handleGoogleRegister"
|
||||
>
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<p v-if="combinedError" class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{{ combinedError }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate-600">
|
||||
Already have an account?
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="text-slate-900 font-semibold hover:text-slate-700"
|
||||
@click.prevent="goToSignIn"
|
||||
>
|
||||
Sign in instead
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
Reference in New Issue
Block a user