Replace Firebase Storage with MinIO and add user account features
- Storage Integration: * Remove Firebase Storage dependency and useFirebaseStorage composable * Implement direct MinIO uploads via POST /storage/upload with multipart/form-data * Upload canvas JSON, preview PNG, and production PNG as separate objects * Store public URLs and metadata in design records - Authentication & Registration: * Add email/password registration page with validation * Integrate backend user session via /auth/login endpoint * Store backendUser.id as ownerId in design records * Auto-sync backend session on Firebase auth state changes - User Account Pages: * Create profile page showing user details and backend session info * Create orders page with transaction history filtered by customerEmail * Add server proxy /api/orders to forward GET /transactions queries - Navigation Improvements: * Replace inline auth buttons with avatar dropdown menu * Add Profile, Orders, and Logout options to dropdown * Implement outside-click and route-change handlers for dropdown * Display user initials in avatar badge - API Updates: * Update transactions endpoint to accept amount as string * Format amount with .toFixed(2) in checkout success flow * Query orders by customerEmail instead of ownerId for consistency
This commit is contained in:
@@ -11,6 +11,7 @@ const {
|
||||
templates,
|
||||
selectedTemplate,
|
||||
selectTemplate,
|
||||
loadDesign,
|
||||
displaySize,
|
||||
templateLabel,
|
||||
productionPixelSize,
|
||||
@@ -41,13 +42,214 @@ const {
|
||||
|
||||
const DESIGN_PRICE_USD = 39.99;
|
||||
|
||||
const { user } = useAuth();
|
||||
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 storageUrl = config.public.storageUrl;
|
||||
if (!storageUrl) {
|
||||
throw new Error("Storage 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: storageUrl,
|
||||
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);
|
||||
};
|
||||
@@ -64,6 +266,18 @@ const handleCheckout = async () => {
|
||||
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;
|
||||
@@ -83,26 +297,42 @@ const handleCheckout = async () => {
|
||||
lastExportedDesign.value = exportResult;
|
||||
|
||||
const designId =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
activeDesignId.value ??
|
||||
(typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `design-${Date.now()}`;
|
||||
: `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,
|
||||
},
|
||||
});
|
||||
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.");
|
||||
@@ -113,11 +343,15 @@ const handleCheckout = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackRedirect = successUrlTemplate.replace("{CHECKOUT_SESSION_ID}", session.id);
|
||||
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.";
|
||||
checkoutError.value =
|
||||
err?.message ?? "Unable to start checkout. Please try again.";
|
||||
} finally {
|
||||
isCheckoutPending.value = false;
|
||||
}
|
||||
@@ -169,7 +403,7 @@ const handleCheckout = async () => {
|
||||
:preview-url="previewUrl"
|
||||
:template-label="templateLabel"
|
||||
:production-pixels="productionPixelSize"
|
||||
:is-exporting="isExporting"
|
||||
:is-exporting="isExporting || isDesignLoading"
|
||||
:is-checkout-pending="isCheckoutPending"
|
||||
:checkout-price="DESIGN_PRICE_USD"
|
||||
:checkout-error="checkoutError"
|
||||
@@ -181,7 +415,9 @@ const handleCheckout = async () => {
|
||||
</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 p-6 shadow-2xl shadow-slate-950/60">
|
||||
<div
|
||||
class="rounded-3xl border border-slate-800/60 bg-linear-to-br from-slate-900 via-slate-950 to-black p-6 shadow-2xl shadow-slate-950/60"
|
||||
>
|
||||
<DesignerCanvas
|
||||
:size="displaySize"
|
||||
:background-color="selectedTemplate.backgroundColor"
|
||||
@@ -190,8 +426,8 @@ const handleCheckout = async () => {
|
||||
/>
|
||||
<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.
|
||||
templates. Use the toolbar to layer text, shapes, and imagery
|
||||
inside the circular boundary.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user