feat: implement new designer page layout with turntable animation and start designing button
This commit is contained in:
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",
|
||||||
|
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>
|
||||||
@@ -1,441 +1,130 @@
|
|||||||
<script setup lang="ts">
|
<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 router = useRouter();
|
||||||
const config = useRuntimeConfig();
|
|
||||||
|
|
||||||
const activeDesignId = ref<string | null>(
|
const startDesigning = () => {
|
||||||
typeof route.query.designId === "string" ? route.query.designId : null
|
router.push('/designer');
|
||||||
);
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="min-h-screen bg-slate-950 pb-16 text-slate-100">
|
<div class="relative min-h-screen overflow-hidden bg-linear-to-br from-slate-950 via-slate-900 to-slate-950">
|
||||||
<AppNavbar />
|
<!-- Background Pattern -->
|
||||||
<div class="mx-auto max-w-6xl px-4 pt-12 sm:px-6 lg:px-8">
|
<div class="absolute inset-0 opacity-20">
|
||||||
<header class="space-y-3">
|
<div class="absolute inset-0" style="background-image: radial-gradient(circle at 1px 1px, rgb(71 85 105) 1px, transparent 0); background-size: 40px 40px;"></div>
|
||||||
<p class="text-sm uppercase tracking-[0.35em] text-sky-400">
|
</div>
|
||||||
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)]">
|
<!-- Animated Slipmat -->
|
||||||
<div class="space-y-6">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
<TemplatePicker
|
<div class="relative">
|
||||||
:templates="templates"
|
<!-- Turntable Base -->
|
||||||
:selected-template-id="selectedTemplate.id"
|
<div class="absolute -inset-48 rounded-full bg-linear-to-br from-slate-800 to-slate-900 opacity-50 blur-3xl"></div>
|
||||||
@select="handleTemplateSelect"
|
|
||||||
/>
|
<!-- Spinning Slipmat -->
|
||||||
|
<div class="relative h-[600px] w-[600px] sm:h-[700px] sm:w-[700px] md:h-[800px] md:w-[800px] animate-spin-slow">
|
||||||
<DesignerPreview
|
<!-- Vinyl Record Background -->
|
||||||
:preview-url="previewUrl"
|
<div class="absolute inset-0 rounded-full bg-linear-to-br from-slate-900 via-slate-800 to-black shadow-2xl"></div>
|
||||||
:template-label="templateLabel"
|
|
||||||
:production-pixels="productionPixelSize"
|
<!-- Grooves -->
|
||||||
:is-exporting="isExporting || isDesignLoading"
|
<div class="absolute inset-8 rounded-full border-2 border-slate-700/30"></div>
|
||||||
:is-checkout-pending="isCheckoutPending"
|
<div class="absolute inset-16 rounded-full border-2 border-slate-700/20"></div>
|
||||||
:checkout-price="DESIGN_PRICE_USD"
|
<div class="absolute inset-24 rounded-full border-2 border-slate-700/10"></div>
|
||||||
:checkout-error="checkoutError"
|
|
||||||
@export="handleExport"
|
<!-- Center Label -->
|
||||||
@download-preview="downloadPreview"
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
@download-production="downloadProduction"
|
<div class="h-48 w-48 sm:h-56 sm:w-56 md:h-64 md:w-64 rounded-full bg-linear-to-br from-sky-500 to-blue-600 shadow-lg flex items-center justify-center">
|
||||||
@checkout="handleCheckout"
|
<div class="h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24 rounded-full bg-slate-950 shadow-inner"></div>
|
||||||
/>
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
|
<!-- Slipmat Design Elements -->
|
||||||
|
<div class="absolute inset-32 rounded-full border-4 border-sky-500/20"></div>
|
||||||
|
|
||||||
|
<!-- Decorative Dots -->
|
||||||
|
<div class="absolute left-1/2 top-16 h-3 w-3 -translate-x-1/2 rounded-full bg-sky-400"></div>
|
||||||
|
<div class="absolute bottom-16 left-1/2 h-3 w-3 -translate-x-1/2 rounded-full bg-sky-400"></div>
|
||||||
|
<div class="absolute left-16 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-sky-400"></div>
|
||||||
|
<div class="absolute right-16 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-sky-400"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<!-- Tonearm -->
|
||||||
|
<div class="absolute -right-24 top-1/2 h-2 w-40 sm:w-48 origin-left -translate-y-1/2 rotate-12 bg-linear-to-r from-slate-700 to-slate-600 shadow-lg"></div>
|
||||||
|
<div class="absolute -right-28 top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-slate-600 shadow-lg"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="relative z-20 flex min-h-screen flex-col items-center justify-between px-4 py-12 text-center">
|
||||||
|
<!-- Top Section - Title & Description -->
|
||||||
|
<div class="w-full space-y-4 pt-8">
|
||||||
|
<h1 class="text-6xl font-bold tracking-tight text-white drop-shadow-2xl sm:text-7xl md:text-8xl lg:text-9xl">
|
||||||
|
Slipmatz
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-sky-400 drop-shadow-lg sm:text-2xl md:text-3xl">
|
||||||
|
Design Custom Slipmats for Your Vinyl
|
||||||
|
</p>
|
||||||
|
<div class="mx-auto max-w-2xl space-y-2 px-4 pt-2">
|
||||||
|
<p class="text-base text-slate-300 drop-shadow-lg sm:text-lg">
|
||||||
|
Create professional, print-ready slipmat designs in minutes.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-slate-400 drop-shadow-md sm:text-base">
|
||||||
|
Perfect for DJs, record labels, and vinyl enthusiasts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Middle Section - Spacer (Turntable is in background) -->
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<!-- Bottom Section - Button & Stats -->
|
||||||
|
<div class="w-full space-y-12 pb-8">
|
||||||
|
<div>
|
||||||
|
<NuxtLink
|
||||||
|
to="/designer"
|
||||||
|
class="group relative inline-flex overflow-hidden rounded-full bg-linear-to-r from-sky-500 to-blue-600 px-12 py-5 text-xl font-bold text-white shadow-2xl transition-all hover:scale-105 hover:shadow-sky-500/50 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>
|
||||||
|
<div class="absolute inset-0 bg-linear-to-r from-blue-600 to-sky-500 opacity-0 transition-opacity group-hover:opacity-100"></div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto grid max-w-4xl gap-8 px-4 sm:grid-cols-3">
|
||||||
|
<div class="space-y-2 backdrop-blur-sm">
|
||||||
|
<div class="text-3xl font-bold text-sky-400 drop-shadow-lg md:text-4xl">12"</div>
|
||||||
|
<div class="text-sm text-slate-400 drop-shadow-md">Standard Size</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 backdrop-blur-sm">
|
||||||
|
<div class="text-3xl font-bold text-sky-400 drop-shadow-lg md:text-4xl">300 DPI</div>
|
||||||
|
<div class="text-sm text-slate-400 drop-shadow-md">Print Quality</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 backdrop-blur-sm">
|
||||||
|
<div class="text-3xl font-bold text-sky-400 drop-shadow-lg md:text-4xl">$39.99</div>
|
||||||
|
<div class="text-sm text-slate-400 drop-shadow-md">Per Design</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Wave -->
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 h-32 bg-linear-to-t from-slate-950 to-transparent"></div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes spin-slow {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-spin-slow {
|
||||||
|
animation: spin-slow 8s linear infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user