feat: implement new designer page layout with turntable animation and start designing button

This commit is contained in:
Frank John Begornia
2025-11-20 21:13:26 +08:00
parent af24186d63
commit fa0d501063
2 changed files with 558 additions and 428 deletions

441
app/pages/designer.vue Normal file
View 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",
headers: {
"content-type": "multipart/form-data",
},
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 }
),
]);
await $fetch("/api/designs", {
method: "POST",
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-slate-950 pb-16 text-slate-100">
<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-sky-400">
Slipmatz Designer
</p>
<h1 class="text-3xl font-bold text-white sm:text-4xl">
Craft custom slipmats ready for the pressing plant.
</h1>
</header>
<section class="mt-10 grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]">
<div class="space-y-6">
<TemplatePicker
:templates="templates"
:selected-template-id="selectedTemplate.id"
@select="handleTemplateSelect"
/>
<DesignerPreview
:preview-url="previewUrl"
:template-label="templateLabel"
:production-pixels="productionPixelSize"
:is-exporting="isExporting || isDesignLoading"
:is-checkout-pending="isCheckoutPending"
:checkout-price="DESIGN_PRICE_USD"
:checkout-error="checkoutError"
@export="handleExport"
@download-preview="downloadPreview"
@download-production="downloadProduction"
@checkout="handleCheckout"
/>
</div>
<div class="flex flex-col gap-6">
<div
class="rounded-3xl border border-slate-800/60 bg-linear-to-br from-slate-900 via-slate-950 to-black shadow-2xl shadow-slate-950/60"
>
<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-400">
Safe zone and bleed guides update automatically when you switch
templates. Use the toolbar to layer text, shapes, and imagery
inside the circular boundary.
</p>
</div>
</div>
</div>
</section>
</div>
</main>
</template>