Compare commits
10
Commits
ac10b33207
...
e23f201d3d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e23f201d3d | ||
|
|
d41aebc4f1 | ||
|
|
b4af94cb31 | ||
|
|
9dbb288fb3 | ||
|
|
bdb5955447 | ||
|
|
e4a84b1979 | ||
|
|
0f119b5db5 | ||
|
|
234f03b4dd | ||
|
|
dc9bceb494 | ||
|
|
e716e8256c |
+2
-1
@@ -5,7 +5,8 @@
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
"port": 3000,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -159,7 +159,35 @@
|
||||
"Bash(curl -s http://127.0.0.1:3000/products/awd-hoodie)",
|
||||
"Bash(curl -s http://127.0.0.1:3000/made-to-order)",
|
||||
"Bash(curl -s \"http://127.0.0.1:3000/products/awd-hoodie\")",
|
||||
"Bash(git commit -m 'Fix: NavDropdown link navigation with stopPropagation *)"
|
||||
"Bash(git commit -m 'Fix: NavDropdown link navigation with stopPropagation *)",
|
||||
"Bash(git commit -m 'Feat: Remove all filters from products pages *)",
|
||||
"Bash(git commit -m 'Feat: Add loading spinner for made-to-order page *)",
|
||||
"Bash(git commit -m 'Revert: Remove loading spinner - was slowing down page *)",
|
||||
"Bash(git commit -m 'Fix: Show approved designs in customer account so they can order *)",
|
||||
"Bash(git commit -m 'Fix: Link approved designs to review page, not personalization *)",
|
||||
"Bash(git commit -m 'Feat: Show estimated delivery date on design review page *)",
|
||||
"Bash(git commit -m 'Fix: Approve & Add to Bag button not working *)",
|
||||
"Bash(git commit -m 'Feat: Add checkout options modal after adding design to bag *)",
|
||||
"PowerShell(Get-Process node -ErrorAction SilentlyContinue)",
|
||||
"PowerShell(Stop-Process -Force -ErrorAction SilentlyContinue)",
|
||||
"PowerShell(npx prisma generate)",
|
||||
"Bash(npm view *)",
|
||||
"Bash(nc -zv 192.168.0.190 5432)",
|
||||
"Bash(psql --version)",
|
||||
"Bash(ssh -o StrictHostKeyChecking=no postgres@192.168.0.190 \"psql -U postgres -c 'SHOW max_connections;'\")",
|
||||
"Bash(ssh -p 22 -o StrictHostKeyChecking=no postgres@192.168.0.190 \"psql -U postgres -c 'SHOW max_connections;'\")",
|
||||
"Bash(ssh -p 22 andymick01@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
|
||||
"Bash(ssh -i ~/.ssh/sshkey -p 22 andymick01@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
|
||||
"Bash(ssh -vv -i ~/.ssh/sshkey -p 22 andymick01@192.168.0.190 \"echo test\")",
|
||||
"Bash(ssh git@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
|
||||
"Bash(ssh -p 22 git@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
|
||||
"Bash(ssh -i ~/.ssh/id_ed25519 -p 22 git@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
|
||||
"Bash(psql -h 192.168.0.190 -p 6432 -U postgres -d craft2prints -c \"SELECT 1 as connection_test;\")",
|
||||
"Bash(npm list *)",
|
||||
"Bash(npm ls *)",
|
||||
"Bash(node -e \"const mod = require\\('fabric'\\); console.log\\(Object.keys\\(mod\\).slice\\(0, 20\\)\\)\")",
|
||||
"Bash(timeout 5 curl -s http://localhost:3000)",
|
||||
"Bash(curl -s http://localhost:3000/made-to-order/awd-t-shirt-at001)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { slug: 'awd-t-shirt-at001' },
|
||||
select: {
|
||||
colorPhotos: true,
|
||||
colorPhotosBack: true
|
||||
}
|
||||
});
|
||||
|
||||
const front = JSON.parse(product.colorPhotos);
|
||||
const back = JSON.parse(product.colorPhotosBack || '{}');
|
||||
|
||||
console.log('Front colors:', Object.keys(front).sort());
|
||||
console.log('Back colors:', Object.keys(back).sort());
|
||||
|
||||
const allColors = new Set([...Object.keys(front), ...Object.keys(back)]);
|
||||
|
||||
console.log('\nMissing in back:');
|
||||
Object.keys(front).forEach(color => {
|
||||
if (!back[color]) {
|
||||
console.log(` ${color} (front has it, back is MISSING)`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nExtra in back (no front):');
|
||||
Object.keys(back).forEach(color => {
|
||||
if (!front[color]) {
|
||||
console.log(` ${color} (back has it, front is missing)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const products = await prisma.product.findMany({
|
||||
where: {
|
||||
slug: {
|
||||
in: ['awd-t-shirt-at001', 'awd-hoodie']
|
||||
}
|
||||
},
|
||||
select: {
|
||||
'slug': true,
|
||||
'name': true,
|
||||
'imageUrl': true,
|
||||
'imageUrlBack': true,
|
||||
'colorPhotos': true,
|
||||
'colorPhotosBack': true,
|
||||
'printArea': true,
|
||||
'printAreaBack': true
|
||||
}
|
||||
});
|
||||
|
||||
products.forEach(p => {
|
||||
console.log(`\n${p.name} (${p.slug}):`);
|
||||
console.log(` Base Images: front=${!!p.imageUrl}, back=${!!p.imageUrlBack}`);
|
||||
const colorPhotos = p.colorPhotos ? JSON.parse(p.colorPhotos) : {};
|
||||
const colorPhotosBack = p.colorPhotosBack ? JSON.parse(p.colorPhotosBack) : {};
|
||||
console.log(` Color Photos: front=${Object.keys(colorPhotos).length}, back=${Object.keys(colorPhotosBack).length}`);
|
||||
console.log(` Print Areas: front=${!!p.printArea}, back=${!!p.printAreaBack}`);
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const products = await prisma.product.findMany({
|
||||
where: {
|
||||
slug: {
|
||||
in: ['awd-t-shirt-at001', 'awd-hoodie']
|
||||
}
|
||||
},
|
||||
select: {
|
||||
'slug': true,
|
||||
'name': true,
|
||||
'printArea': true,
|
||||
'printAreaBack': true,
|
||||
'mockup': true
|
||||
}
|
||||
});
|
||||
|
||||
products.forEach(p => {
|
||||
console.log(`\n${p.name} (${p.slug}):`);
|
||||
console.log(` Mockup: ${p.mockup}`);
|
||||
const front = JSON.parse(p.printArea);
|
||||
console.log(` Front Print Area: x=${front.xPct}, y=${front.yPct}, w=${front.wPct}, h=${front.hPct}`);
|
||||
if (p.printAreaBack) {
|
||||
const back = JSON.parse(p.printAreaBack);
|
||||
console.log(` Back Print Area: x=${back.xPct}, y=${back.yPct}, w=${back.wPct}, h=${back.hPct}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { slug: 'awd-t-shirt-at001' }
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
console.log('Product not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n=== T-Shirt Images ===');
|
||||
console.log(`\nColors: ${product.colors}`);
|
||||
console.log(`\nimagUrl: ${product.imageUrl ? 'SET' : 'NOT SET'}`);
|
||||
console.log(`imageUrlBack: ${product.imageUrlBack ? 'SET' : 'NOT SET'}`);
|
||||
|
||||
if (product.colorPhotos) {
|
||||
const colors = JSON.parse(product.colorPhotos);
|
||||
console.log(`\ncolorPhotos: ${Object.keys(colors).length} colors have photos`);
|
||||
Object.keys(colors).forEach(color => {
|
||||
console.log(` - ${color}: ${colors[color] ? 'HAS IMAGE' : 'NO IMAGE'}`);
|
||||
});
|
||||
} else {
|
||||
console.log('\ncolorPhotos: NOT SET');
|
||||
}
|
||||
|
||||
if (product.colorPhotosBack) {
|
||||
const colors = JSON.parse(product.colorPhotosBack);
|
||||
console.log(`\ncolorPhotosBack: ${Object.keys(colors).length} colors have back photos`);
|
||||
} else {
|
||||
console.log('\ncolorPhotosBack: NOT SET');
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const products = await prisma.product.findMany({
|
||||
where: {
|
||||
slug: {
|
||||
in: ['awd-t-shirt-at001', 'awd-hoodie']
|
||||
}
|
||||
},
|
||||
select: {
|
||||
'id': true,
|
||||
'slug': true,
|
||||
'name': true,
|
||||
'imageUrl': true,
|
||||
'imageUrlBack': true
|
||||
}
|
||||
});
|
||||
|
||||
products.forEach(p => {
|
||||
console.log(`\n${p.name} (${p.slug}):`);
|
||||
console.log(` Front: ${p.imageUrl ? p.imageUrl.substring(0, 60) + '...' : 'NOT SET'}`);
|
||||
console.log(` Back: ${p.imageUrlBack ? p.imageUrlBack.substring(0, 60) + '...' : 'NOT SET'}`);
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Vendored
+2
-1
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
+1
-2
@@ -1,9 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
serverExternalPackages: ['@prisma/client'],
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['@prisma/client'],
|
||||
// Invoice uploads (phone photos/PDFs) and product photo uploads go through
|
||||
// server actions — the 1MB default rejects typical phone photos.
|
||||
serverActions: { bodySizeLimit: '10mb' },
|
||||
|
||||
Generated
+1678
-102
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -14,10 +14,11 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "5.16.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"fabric": "^7.4.0",
|
||||
"idb-keyval": "6.2.1",
|
||||
"jose": "^6.2.3",
|
||||
"konva": "9.3.14",
|
||||
"next": "14.2.5",
|
||||
"next": "^16.2.11",
|
||||
"nodemailer": "6.9.14",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"react": "18.3.1",
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function registerCustomer(formData: FormData) {
|
||||
if (password !== confirmPassword) redirect('/account/register?error=mismatch');
|
||||
if (password.length < 8) redirect('/account/register?error=weak');
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const ip = getClientIp(await headers());
|
||||
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
|
||||
const { success } = await verifyTurnstileToken(turnstileToken, ip);
|
||||
if (!success) redirect('/account/register?error=captcha');
|
||||
@@ -61,8 +61,9 @@ export async function loginCustomer(formData: FormData) {
|
||||
const password = String(formData.get('password') ?? '');
|
||||
const next = safeNext(formData.get('next'));
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const userAgent = headers().get('user-agent') ?? undefined;
|
||||
const headersList = await headers();
|
||||
const ip = getClientIp(headersList);
|
||||
const userAgent = headersList.get('user-agent') ?? undefined;
|
||||
|
||||
const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000);
|
||||
if (!allowed) {
|
||||
@@ -95,7 +96,7 @@ export async function loginCustomer(formData: FormData) {
|
||||
}
|
||||
|
||||
export async function logoutCustomer() {
|
||||
clearSession();
|
||||
await clearSession();
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ export default async function AccountPage({ searchParams }: { searchParams: { su
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const approvedDesigns = await prisma.designApproval.findMany({
|
||||
where: { customerEmail: customer.email, status: 'APPROVED' },
|
||||
include: { product: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Separate recent (< 7 days) and older (>= 7 days) orders
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
@@ -121,6 +127,39 @@ export default async function AccountPage({ searchParams }: { searchParams: { su
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="mt-10 font-display text-xl">Ready to order</h2>
|
||||
{approvedDesigns.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted">No approved designs ready to order.</p>
|
||||
) : (
|
||||
<div className="mt-4 divide-y divide-line border-t border-line">
|
||||
{approvedDesigns.map((design) => (
|
||||
<Link
|
||||
key={design.id}
|
||||
href={`/designs/${design.id}/review`}
|
||||
className="flex items-center gap-4 py-4 hover:bg-surface"
|
||||
>
|
||||
<img
|
||||
src={design.designPreviewUrl}
|
||||
alt="design preview"
|
||||
className="h-14 w-14 border border-line object-cover"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{design.product.name}</p>
|
||||
<p className="text-sm text-muted">
|
||||
Approved {new Date(design.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })}
|
||||
</p>
|
||||
{design.expectedDeliveryDate && (
|
||||
<p className="text-xs text-muted mt-1">
|
||||
Est. delivery: {new Date(design.expectedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-clay">Order →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="mt-10 font-display text-xl">Order history</h2>
|
||||
{orders.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted">No orders yet.</p>
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export async function subscribeToNewsletter(email: string, turnstileToken: strin
|
||||
return { ok: false, message: 'Enter a valid email address.' };
|
||||
}
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const ip = getClientIp(await headers());
|
||||
const { success } = await verifyTurnstileToken(turnstileToken, ip);
|
||||
if (!success) {
|
||||
return { ok: false, message: 'CAPTCHA verification failed — please try again.' };
|
||||
|
||||
@@ -33,8 +33,8 @@ export default async function AccountsPage({
|
||||
// Include the whole "to" day, not just its midnight.
|
||||
const to = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null;
|
||||
|
||||
let orders = [];
|
||||
let manualSales = [];
|
||||
let orders: any[] = [];
|
||||
let manualSales: any[] = [];
|
||||
|
||||
try {
|
||||
[orders, manualSales] = await Promise.all([
|
||||
@@ -59,22 +59,22 @@ export default async function AccountsPage({
|
||||
}
|
||||
|
||||
const entries: LedgerEntry[] = [
|
||||
...orders.map((o) => ({
|
||||
...orders.map((o: any) => ({
|
||||
id: o.id,
|
||||
kind: 'WEB' as const,
|
||||
date: o.createdAt,
|
||||
who: o.customer?.name ?? o.email ?? 'Guest',
|
||||
what: o.items.map((i) => `${i.quantity}× ${i.productName}`).join(', '),
|
||||
what: o.items.map((i: any) => `${i.quantity}× ${i.productName}`).join(', '),
|
||||
method: 'Card (website)',
|
||||
total: o.total,
|
||||
href: `/admin/orders`,
|
||||
})),
|
||||
...manualSales.map((s) => ({
|
||||
...manualSales.map((s: any) => ({
|
||||
id: s.id,
|
||||
kind: 'MANUAL' as const,
|
||||
date: s.soldAt,
|
||||
who: s.buyerName || s.buyerEmail || 'Unnamed buyer',
|
||||
what: s.items.map((i) => `${i.quantity}× ${i.description}`).join(', '),
|
||||
what: s.items.map((i: any) => `${i.quantity}× ${i.description}`).join(', '),
|
||||
method: PAYMENT_LABELS[s.paymentMethod] ?? s.paymentMethod,
|
||||
total: s.total,
|
||||
href: null,
|
||||
|
||||
@@ -4,8 +4,8 @@ import AccountsNav from '@/components/AccountsNav';
|
||||
import StockAdjustForm from '@/components/StockAdjustForm';
|
||||
|
||||
export default async function StockPage() {
|
||||
let levels = [];
|
||||
let productRows = [];
|
||||
let levels: any[] = [];
|
||||
let productRows: any[] = [];
|
||||
|
||||
try {
|
||||
[levels, productRows] = await Promise.all([
|
||||
@@ -18,7 +18,7 @@ export default async function StockPage() {
|
||||
console.error('Error fetching stock data:', err);
|
||||
}
|
||||
|
||||
const products = productRows.map((p) => ({
|
||||
const products = productRows.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
colors: JSON.parse(p.colors) as string[],
|
||||
@@ -27,7 +27,7 @@ export default async function StockPage() {
|
||||
|
||||
// Group rows under their product for a readable table.
|
||||
const grouped = new Map<string, typeof levels>();
|
||||
for (const level of levels.sort((a, b) => {
|
||||
for (const level of levels.sort((a: any, b: any) => {
|
||||
if (a.product.name !== b.product.name) return a.product.name.localeCompare(b.product.name);
|
||||
if (a.color !== b.color) return a.color.localeCompare(b.color);
|
||||
return (a.size || '').localeCompare(b.size || '');
|
||||
|
||||
@@ -14,14 +14,14 @@ export default async function CustomersPage({
|
||||
}: {
|
||||
searchParams: { sent?: string; failed?: string };
|
||||
}) {
|
||||
let entries = [];
|
||||
let entries: any[] = [];
|
||||
try {
|
||||
entries = await prisma.mailingListEntry.findMany();
|
||||
entries.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
entries.sort((a: any, b: any) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
} catch (err) {
|
||||
console.error('Error fetching mailing list:', err);
|
||||
}
|
||||
const subscribedCount = entries.filter((e) => e.subscribed).length;
|
||||
const subscribedCount = entries.filter((e: any) => e.subscribed).length;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||
|
||||
@@ -4,33 +4,14 @@ import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { ProductDTO } from '@/lib/types';
|
||||
|
||||
interface DesignApproval {
|
||||
id: string;
|
||||
customerEmail: string;
|
||||
customerName: string | null;
|
||||
productId: string;
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
basePrice: number;
|
||||
personalisedPrice: number;
|
||||
onSale: boolean;
|
||||
effectivePrice: number;
|
||||
colors: string[];
|
||||
sizes: string[];
|
||||
showOnPersonalised: boolean;
|
||||
imageUrl: string | null;
|
||||
imageUrlBack: string | null;
|
||||
photoRecolorable: boolean;
|
||||
colorPhotos: Record<string, string>;
|
||||
colorPhotosBack: Record<string, string>;
|
||||
printArea: string;
|
||||
printAreaBack: string | null;
|
||||
mockup: string;
|
||||
referenceWidthCm: number | null;
|
||||
referenceHeightCm: number | null;
|
||||
};
|
||||
product: ProductDTO;
|
||||
designJson: string;
|
||||
color: string;
|
||||
size: string | null;
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function DesignDetailPage({ params }: { params: { id: string } })
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
setDesign(updated);
|
||||
alert(`✓ Design approved! Customer will receive an email with a link to order.${expectedDeliveryDate ? ` Estimated delivery: ${new Date(expectedDeliveryDate).toLocaleDateString()}` : ''}`);
|
||||
}
|
||||
} finally {
|
||||
setApproving(false);
|
||||
|
||||
@@ -4,7 +4,7 @@ import AdminNav from '@/components/AdminNav';
|
||||
import DeleteDesignButton from '@/components/DeleteDesignButton';
|
||||
|
||||
export default async function DesignsPage() {
|
||||
let designs = [];
|
||||
let designs: any[] = [];
|
||||
try {
|
||||
designs = await prisma.designApproval.findMany({
|
||||
select: {
|
||||
@@ -20,15 +20,15 @@ export default async function DesignsPage() {
|
||||
});
|
||||
|
||||
// Fetch products separately to avoid relation issues
|
||||
const productIds = [...new Set(designs.map(d => d.productId))];
|
||||
const productIds = [...new Set(designs.map((d: any) => d.productId))];
|
||||
const products = await prisma.product.findMany({
|
||||
where: { id: { in: productIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const productMap = Object.fromEntries(products.map(p => [p.id, p]));
|
||||
const productMap = Object.fromEntries(products.map((p: any) => [p.id, p]));
|
||||
|
||||
// Add product to each design
|
||||
designs = designs.map(d => ({
|
||||
designs = designs.map((d: any) => ({
|
||||
...d,
|
||||
product: productMap[d.productId],
|
||||
messages: [],
|
||||
@@ -37,9 +37,9 @@ export default async function DesignsPage() {
|
||||
console.error('Error fetching designs:', err);
|
||||
}
|
||||
|
||||
const pending = designs.filter((d) => d.status === 'PENDING');
|
||||
const approved = designs.filter((d) => d.status === 'APPROVED');
|
||||
const rejected = designs.filter((d) => d.status === 'REJECTED');
|
||||
const pending = designs.filter((d: any) => d.status === 'PENDING');
|
||||
const approved = designs.filter((d: any) => d.status === 'APPROVED');
|
||||
const rejected = designs.filter((d: any) => d.status === 'REJECTED');
|
||||
|
||||
const DesignList = ({ items, status }: { items: typeof designs; status: string }) => (
|
||||
<div className="mt-8">
|
||||
|
||||
@@ -17,8 +17,9 @@ export async function loginAdmin(formData: FormData) {
|
||||
const password = String(formData.get('password') ?? '');
|
||||
const next = safeNext(formData.get('next'));
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const userAgent = headers().get('user-agent') ?? undefined;
|
||||
const headersList = await headers();
|
||||
const ip = getClientIp(headersList);
|
||||
const userAgent = headersList.get('user-agent') ?? undefined;
|
||||
|
||||
const { allowed } = await checkRateLimit(`login:admin:${ip}`, 5, 15 * 60 * 1000);
|
||||
if (!allowed) {
|
||||
|
||||
@@ -5,9 +5,9 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
rate_limited: 'Too many attempts — please wait a few minutes and try again.',
|
||||
};
|
||||
|
||||
export default function AdminLoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) {
|
||||
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
|
||||
const next = searchParams.next ?? '/admin/dashboard';
|
||||
export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
|
||||
const { error: errorKey, next } = await searchParams;
|
||||
const error = errorKey ? (ERROR_MESSAGES[errorKey] ?? 'Something went wrong.') : null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-6 py-16">
|
||||
|
||||
@@ -237,8 +237,9 @@ export default async function OrderDetailPage({ params }: { params: { id: string
|
||||
{order.items.map((item) => {
|
||||
let hasFrontDesign = false;
|
||||
let hasBackDesign = false;
|
||||
let design: { front: unknown[]; back: unknown[] } = { front: [], back: [] };
|
||||
try {
|
||||
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
|
||||
design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
|
||||
hasFrontDesign = (design.front?.length ?? 0) > 0;
|
||||
hasBackDesign = (design.back?.length ?? 0) > 0;
|
||||
} catch {
|
||||
|
||||
@@ -37,7 +37,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
|
||||
const pageSize = 50;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
let orders = [];
|
||||
let orders: any[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
// Fetch orders and total count in parallel
|
||||
@@ -80,8 +80,8 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
|
||||
|
||||
// Filter to personalised orders if requested
|
||||
if (showOnlyPersonalised) {
|
||||
orders = orders.filter((order) =>
|
||||
order.items.some((item) => {
|
||||
orders = orders.filter((order: any) =>
|
||||
order.items.some((item: any) => {
|
||||
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
|
||||
return design.front.length > 0 || design.back.length > 0;
|
||||
})
|
||||
@@ -148,8 +148,8 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{orders.map((order) => {
|
||||
const hasPersonalised = order.items.some((item) => {
|
||||
{orders.map((order: any) => {
|
||||
const hasPersonalised = order.items.some((item: any) => {
|
||||
try {
|
||||
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
|
||||
return (design.front?.length ?? 0) > 0 || (design.back?.length ?? 0) > 0;
|
||||
|
||||
@@ -9,25 +9,22 @@ import SizePicker from '@/components/SizePicker';
|
||||
import PhotoPrintAreaField, { type Box } from '@/components/PhotoPrintAreaField';
|
||||
import CategoryPicker from '@/components/CategoryPicker';
|
||||
|
||||
export default async function EditProductPage({ params }: { params: { id: string } }) {
|
||||
export default async function EditProductPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
const row = await prisma.product.findUnique({ where: { id: params.id }, include: { collections: true } });
|
||||
const row = await prisma.product.findUnique({ where: { id }, include: { collections: true } });
|
||||
if (!row) notFound();
|
||||
const product = toProductDTO(row, activePromotions);
|
||||
|
||||
const categories = await prisma.category.findMany({
|
||||
where: { parentId: null },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
include: {
|
||||
children: {
|
||||
select: { id: true, name: true, slug: true },
|
||||
orderBy: { name: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}) as any;
|
||||
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
|
||||
const occasions = collections.filter((c) => c.group === 'OCCASION');
|
||||
const recipients = collections.filter((c) => c.group === 'RECIPIENT');
|
||||
@@ -49,6 +46,10 @@ export default async function EditProductPage({ params }: { params: { id: string
|
||||
hPct: printAreaBack.hPct * 100,
|
||||
} : undefined;
|
||||
|
||||
// Parse color photos for preview if no base image
|
||||
const colorPhotos = row.colorPhotos ? JSON.parse(row.colorPhotos) : undefined;
|
||||
const colorPhotosBack = row.colorPhotosBack ? JSON.parse(row.colorPhotosBack) : undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
@@ -279,12 +280,12 @@ export default async function EditProductPage({ params }: { params: { id: string
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Front photo</label>
|
||||
<PhotoPrintAreaField existingImageUrl={product.imageUrl} existingPrintArea={existingPrintArea} />
|
||||
<PhotoPrintAreaField existingImageUrl={product.imageUrl ?? undefined} existingPrintArea={existingPrintArea} colorPhotosPreview={colorPhotos} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Back photo</label>
|
||||
<PhotoPrintAreaField variant="back" existingImageUrl={product.imageUrlBack} existingPrintArea={existingPrintAreaBack} />
|
||||
<PhotoPrintAreaField variant="back" existingImageUrl={product.imageUrlBack ?? undefined} existingPrintArea={existingPrintAreaBack} colorPhotosPreview={colorPhotosBack} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -276,10 +276,10 @@ export async function updateProduct(formData: FormData) {
|
||||
referenceWidthCm: number | null;
|
||||
referenceHeightCm: number | null;
|
||||
weightGrams: number | null;
|
||||
imageUrl?: string;
|
||||
imageUrlBack?: string;
|
||||
imageUrl?: string | null;
|
||||
imageUrlBack?: string | null;
|
||||
printArea?: string;
|
||||
printAreaBack?: string;
|
||||
printAreaBack?: string | null;
|
||||
} = {
|
||||
name,
|
||||
category,
|
||||
|
||||
@@ -10,16 +10,12 @@ export default async function NewProductPage() {
|
||||
const categories = await prisma.category.findMany({
|
||||
where: { parentId: null },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
include: {
|
||||
children: {
|
||||
select: { id: true, name: true, slug: true },
|
||||
orderBy: { name: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}) as any;
|
||||
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
|
||||
const occasions = collections.filter((c) => c.group === 'OCCASION');
|
||||
const recipients = collections.filter((c) => c.group === 'RECIPIENT');
|
||||
|
||||
@@ -9,13 +9,14 @@ import { deleteProduct } from './actions';
|
||||
export default async function AdminProductsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { error?: string };
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { error } = await searchParams;
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
let products = [];
|
||||
let products: any[] = [];
|
||||
try {
|
||||
const rows = await prisma.product.findMany();
|
||||
products = rows.map((p) => toProductDTO(p, activePromotions)).sort((a, b) =>
|
||||
products = rows.map((p: any) => toProductDTO(p, activePromotions)).sort((a: any, b: any) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -35,7 +36,7 @@ export default async function AdminProductsPage({
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{searchParams.error === 'in_use' && (
|
||||
{error === 'in_use' && (
|
||||
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
|
||||
Can't delete that product — it has existing orders or design proofs tied to it.
|
||||
</div>
|
||||
|
||||
@@ -130,7 +130,7 @@ export async function POST(req: Request) {
|
||||
|
||||
// Calculate shipping cost (skip for collection orders)
|
||||
let shippingCost = 0;
|
||||
let selectedShippingService = shippingService;
|
||||
let selectedShippingService: string | undefined | null = shippingService;
|
||||
|
||||
if (isCollection) {
|
||||
console.log('Collection order - no shipping cost');
|
||||
@@ -193,7 +193,7 @@ export async function POST(req: Request) {
|
||||
email: guest?.email ?? customer?.email,
|
||||
guestName: guest?.name,
|
||||
guestPhone: guest?.phone,
|
||||
isCollectionPickup,
|
||||
isCollectionPickup: isCollection,
|
||||
items: {
|
||||
create: pricedItems.map((i) => ({
|
||||
productId: i.productId,
|
||||
|
||||
@@ -5,14 +5,15 @@ import { toProductDTO } from '@/lib/product';
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const { expectedDeliveryDate } = body;
|
||||
|
||||
const design = await prisma.designApproval.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
include: { product: true },
|
||||
});
|
||||
|
||||
@@ -21,7 +22,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
const updated = await prisma.designApproval.update({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
expectedDeliveryDate: expectedDeliveryDate ? new Date(expectedDeliveryDate) : null,
|
||||
|
||||
@@ -4,11 +4,12 @@ import type { DesignApprovalMessage } from '@prisma/client';
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const messages = await prisma.designApprovalMessage.findMany({
|
||||
where: { designId: params.id },
|
||||
where: { designId: id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
@@ -24,9 +25,10 @@ export async function GET(
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { sender, body, role, message, messageType } = await req.json();
|
||||
|
||||
// Support both old format (sender/body) and new format (role/message/messageType)
|
||||
@@ -43,7 +45,7 @@ export async function POST(
|
||||
|
||||
const created = await prisma.designApprovalMessage.create({
|
||||
data: {
|
||||
designId: params.id,
|
||||
designId: id,
|
||||
sender: finalSender,
|
||||
body: finalBody,
|
||||
messageType: finalMessageType,
|
||||
|
||||
@@ -3,9 +3,10 @@ import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const {
|
||||
designJson,
|
||||
designPreviewUrl,
|
||||
@@ -17,7 +18,7 @@ export async function POST(
|
||||
} = await req.json();
|
||||
|
||||
const design = await prisma.designApproval.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
if (!design) {
|
||||
@@ -25,7 +26,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
const updated = await prisma.designApproval.update({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
data: {
|
||||
...(designJson && { designJson }),
|
||||
...(designPreviewUrl && { designPreviewUrl }),
|
||||
|
||||
@@ -5,11 +5,12 @@ import { toProductDTO } from '@/lib/product';
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const design = await prisma.designApproval.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
|
||||
});
|
||||
|
||||
@@ -32,9 +33,10 @@ export async function GET(
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
@@ -43,7 +45,7 @@ export async function PATCH(
|
||||
const { status } = await req.json();
|
||||
|
||||
const updated = await prisma.designApproval.update({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
data: { status },
|
||||
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
|
||||
});
|
||||
@@ -63,17 +65,18 @@ export async function PATCH(
|
||||
|
||||
export async function DELETE(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
// Delete associated messages first
|
||||
await prisma.designApprovalMessage.deleteMany({
|
||||
where: { designId: params.id },
|
||||
where: { designId: id },
|
||||
});
|
||||
|
||||
// Delete the design approval
|
||||
await prisma.designApproval.delete({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
@@ -4,13 +4,14 @@ import { sendDesignReadyForReview } from '@/lib/mail';
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { designPreviewUrl, designPreviewUrlBack, placementPreviewUrl, placementPreviewUrlBack } = await req.json().catch(() => ({}));
|
||||
|
||||
const design = await prisma.designApproval.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
include: { product: true },
|
||||
});
|
||||
|
||||
@@ -26,7 +27,7 @@ export async function POST(
|
||||
if (placementPreviewUrlBack) updateData.placementPreviewUrlBack = placementPreviewUrlBack;
|
||||
|
||||
const updated = await prisma.designApproval.update({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
data: updateData,
|
||||
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
|
||||
});
|
||||
@@ -36,7 +37,7 @@ export async function POST(
|
||||
customerName: design.customerName || 'Customer',
|
||||
customerEmail: design.customerEmail,
|
||||
productName: design.product.name,
|
||||
reviewLink: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/designs/${params.id}/review`,
|
||||
reviewLink: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/designs/${id}/review`,
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(req: Request, { params }: { params: { id: string } }) {
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const { id } = await params;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: { items: true },
|
||||
@@ -292,7 +292,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
|
||||
${item.designImageDimensions ? (() => {
|
||||
try {
|
||||
const imageDims = JSON.parse(item.designImageDimensions);
|
||||
return imageDims && imageDims.length > 0 ? `<p><strong>Image Dimensions:</strong><ul style="margin: 5px 0 0 20px;">${imageDims.map((img, idx) => `<li>Image ${idx + 1}: ${img.widthCm} cm × ${img.heightCm} cm</li>`).join('')}</ul></p>` : '';
|
||||
return imageDims && imageDims.length > 0 ? `<p><strong>Image Dimensions:</strong><ul style="margin: 5px 0 0 20px;">${imageDims.map((img: any, idx: any) => `<li>Image ${idx + 1}: ${img.widthCm} cm × ${img.heightCm} cm</li>`).join('')}</ul></p>` : '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { fetchRoyalMailTracking, mapTrackingStatusToOrderStatus } from '@/lib/ro
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
@@ -31,13 +32,13 @@ export async function POST(
|
||||
|
||||
// Update order with new status
|
||||
const updated = await prisma.order.update({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
data: {
|
||||
status: newStatus,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✓ Order ${params.id} tracking synced: ${trackingData.status} → ${newStatus}`);
|
||||
console.log(`✓ Order ${id} tracking synced: ${trackingData.status} → ${newStatus}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function POST(_req: Request, { params }: { params: { id: string } }) {
|
||||
const proof = await prisma.designProof.findUnique({ where: { id: params.id } });
|
||||
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const proof = await prisma.designProof.findUnique({ where: { id } });
|
||||
if (!proof) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.designProof.update({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
data: { status: 'APPROVED' },
|
||||
});
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import type { MessageDTO } from '@/lib/types';
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: { id: string } }) {
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const rows = await prisma.message.findMany({
|
||||
where: { proofId: params.id },
|
||||
where: { proofId: id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const messages: MessageDTO[] = rows.map((m) => ({
|
||||
const messages: MessageDTO[] = rows.map((m: any) => ({
|
||||
id: m.id,
|
||||
proofId: m.proofId,
|
||||
sender: m.sender as MessageDTO['sender'],
|
||||
@@ -19,7 +20,8 @@ export async function GET(_req: Request, { params }: { params: { id: string } })
|
||||
return NextResponse.json(messages);
|
||||
}
|
||||
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { sender, body } = await req.json();
|
||||
|
||||
if (sender !== 'ADMIN' && sender !== 'CUSTOMER') {
|
||||
@@ -30,13 +32,13 @@ export async function POST(req: Request, { params }: { params: { id: string } })
|
||||
return NextResponse.json({ error: 'Message body is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const proof = await prisma.designProof.findUnique({ where: { id: params.id } });
|
||||
const proof = await prisma.designProof.findUnique({ where: { id } });
|
||||
if (!proof) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const created = await prisma.message.create({
|
||||
data: { proofId: params.id, sender, body: trimmed },
|
||||
data: { proofId: id, sender, body: trimmed },
|
||||
});
|
||||
|
||||
const message: MessageDTO = {
|
||||
|
||||
@@ -3,11 +3,12 @@ import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function DELETE(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const review = await prisma.review.delete({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
return NextResponse.json(review);
|
||||
@@ -22,14 +23,15 @@ export async function DELETE(
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const { approved } = body;
|
||||
|
||||
const review = await prisma.review.update({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
data: { approved },
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function sendContactMessage(formData: FormData) {
|
||||
redirect('/contact?error=missing');
|
||||
}
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const ip = getClientIp(await headers());
|
||||
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
|
||||
const { success } = await verifyTurnstileToken(turnstileToken, ip);
|
||||
if (!success) {
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function createCustomRequest(formData: FormData) {
|
||||
throw new Error('Missing required fields: product, name, email, and a photo are all required.');
|
||||
}
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const ip = getClientIp(await headers());
|
||||
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
|
||||
const { success } = await verifyTurnstileToken(turnstileToken, ip);
|
||||
if (!success) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCart } from '@/lib/cartStore';
|
||||
|
||||
interface DesignApproval {
|
||||
@@ -18,18 +19,23 @@ interface DesignApproval {
|
||||
};
|
||||
designJson: string;
|
||||
designPreviewUrl: string;
|
||||
designPreviewUrlBack: string | null;
|
||||
placementPreviewUrl: string | null;
|
||||
placementPreviewUrlBack: string | null;
|
||||
color: string;
|
||||
size: string | null;
|
||||
status: string;
|
||||
expectedDeliveryDate: string | null;
|
||||
}
|
||||
|
||||
export default function DesignReviewPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter();
|
||||
const [design, setDesign] = useState<DesignApproval | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [approving, setApproving] = useState(false);
|
||||
const [changingMessage, setChangingMessage] = useState('');
|
||||
const [sendingChanges, setSendingChanges] = useState(false);
|
||||
const [showCheckoutOptions, setShowCheckoutOptions] = useState(false);
|
||||
const addItem = useCart((s) => s.addItem);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,7 +49,11 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
|
||||
if (!design) return;
|
||||
setApproving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/designs/${params.id}/approve`, { method: 'POST' });
|
||||
const res = await fetch(`/api/designs/${params.id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
setDesign(updated);
|
||||
@@ -59,7 +69,7 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
|
||||
size: design.size,
|
||||
quantity: 1,
|
||||
unitPrice: design.product.personalisedPrice ?? design.product.effectivePrice,
|
||||
designJson: design.designJson,
|
||||
designJson: JSON.parse(design.designJson),
|
||||
designPreviewUrl: design.designPreviewUrl,
|
||||
designPreviewUrlBack: design.designPreviewUrlBack,
|
||||
placementPreviewUrl: design.placementPreviewUrl,
|
||||
@@ -68,8 +78,13 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
|
||||
designHeightCm: null,
|
||||
});
|
||||
|
||||
alert('Design approved! Added to your bag.');
|
||||
setShowCheckoutOptions(true);
|
||||
} else {
|
||||
alert('Failed to approve design. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving design:', error);
|
||||
alert('An error occurred. Please try again.');
|
||||
} finally {
|
||||
setApproving(false);
|
||||
}
|
||||
@@ -167,6 +182,11 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
|
||||
<p className="border-t border-line pt-3">
|
||||
<strong>Price:</strong> £{((design.product.personalisedPrice ?? design.product.effectivePrice) / 100).toFixed(2)}
|
||||
</p>
|
||||
{design.expectedDeliveryDate && (
|
||||
<p className="text-xs text-muted pt-2">
|
||||
<strong>Est. delivery:</strong> {new Date(design.expectedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -197,6 +217,32 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkout Options Modal */}
|
||||
{showCheckoutOptions && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-paper border border-line rounded-lg p-8 max-w-md mx-4 shadow-lg">
|
||||
<h2 className="font-display text-2xl mb-2">✓ Design approved!</h2>
|
||||
<p className="text-sm text-muted mb-6">
|
||||
Your design has been added to your bag. What would you like to do next?
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => router.push('/checkout')}
|
||||
className="w-full bg-clay px-4 py-3 text-sm font-medium text-paper hover:bg-clay-dark rounded"
|
||||
>
|
||||
Go to Checkout
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCheckoutOptions(false)}
|
||||
className="w-full border border-ink px-4 py-3 text-sm font-medium text-ink hover:border-clay hover:bg-clay hover:text-paper rounded"
|
||||
>
|
||||
Keep Shopping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import Reveal from '@/components/Reveal';
|
||||
|
||||
export default async function GalleryPage({ searchParams }: { searchParams: { category?: string } }) {
|
||||
export default async function GalleryPage({ searchParams }: { searchParams: Promise<{ category?: string }> }) {
|
||||
const { category } = await searchParams;
|
||||
const categories = await prisma.galleryCategory.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const activeSlug = searchParams.category ?? null;
|
||||
const activeSlug = category ?? null;
|
||||
const activeCategory = activeSlug ? categories.find((c) => c.slug === activeSlug) ?? null : null;
|
||||
|
||||
const photos = await prisma.galleryPhoto.findMany({
|
||||
|
||||
+3
-14
@@ -7,6 +7,7 @@ import Footer from '@/components/Footer';
|
||||
import PromotionBanner from '@/components/PromotionBanner';
|
||||
import MaintenancePage from '@/components/MaintenancePage';
|
||||
import LogoutOnUnload from '@/components/LogoutOnUnload';
|
||||
import ThemeInitializer from '@/components/ThemeInitializer';
|
||||
import { CurrencyProvider } from '@/lib/CurrencyContext';
|
||||
import { getSiteSettings } from '@/lib/settings';
|
||||
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
||||
@@ -31,7 +32,7 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const { maintenanceMode, maintenanceMessage } = await getSiteSettings();
|
||||
const pathname = headers().get('x-pathname') ?? '';
|
||||
const pathname = (await headers()).get('x-pathname') ?? '';
|
||||
// Admins bypass maintenance (they can keep working); /admin/* is always
|
||||
// reachable so the owner can log in and toggle it back off.
|
||||
// Also allow /checkout/* pages so customers can complete purchases and see results.
|
||||
@@ -50,21 +51,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600&family=Oswald:wght@500&family=Bebas+Neue&family=Pacifico&family=Permanent+Marker&family=Caveat:wght@600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
{/* Runs before paint so there's no flash of the wrong theme on load. */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
var stored = localStorage.getItem('theme');
|
||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var isDark = stored ? stored === 'dark' : prefersDark;
|
||||
if (isDark) document.documentElement.classList.add('dark');
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning className="flex min-h-screen flex-col">
|
||||
<ThemeInitializer />
|
||||
<LogoutOnUnload />
|
||||
<CurrencyProvider>
|
||||
{showMaintenance ? (
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { toProductDTO } from '@/lib/product';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
import ProductCard from '@/components/ProductCard';
|
||||
|
||||
const DesignCanvas = dynamic(() => import('@/components/DesignCanvas'), { ssr: false });
|
||||
import DesignCanvasWrapper from '@/components/DesignCanvasWrapper';
|
||||
|
||||
export default async function ProductDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
searchParams: { customRequestId?: string };
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ customRequestId?: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { customRequestId } = await searchParams;
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
|
||||
const row = await prisma.product.findUnique({ where: { slug: params.slug } });
|
||||
const row = await prisma.product.findUnique({ where: { slug } });
|
||||
if (!row || !row.showOnPersonalised) notFound();
|
||||
|
||||
const product = toProductDTO(row, activePromotions, 'personalised');
|
||||
@@ -29,9 +29,9 @@ export default async function ProductDetailPage({
|
||||
|
||||
// Fetch custom photo from request if customRequestId is provided
|
||||
let customPhoto: string | undefined;
|
||||
if (searchParams.customRequestId) {
|
||||
if (customRequestId) {
|
||||
const customRequest = await prisma.customRequest.findUnique({
|
||||
where: { id: searchParams.customRequestId },
|
||||
where: { id: customRequestId },
|
||||
});
|
||||
if (customRequest) {
|
||||
customPhoto = customRequest.imageDataUrl;
|
||||
@@ -40,7 +40,7 @@ export default async function ProductDetailPage({
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-12">
|
||||
<DesignCanvas product={product} customPhoto={customPhoto} />
|
||||
<DesignCanvasWrapper product={product} customPhoto={customPhoto} />
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="mt-20 border-t border-line pt-12">
|
||||
|
||||
+38
-257
@@ -1,300 +1,81 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { toProductDTO } from '@/lib/product';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
import { getPersonalisedPaletteColors } from '@/lib/cache';
|
||||
import ProductCard from '@/components/ProductCard';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
type SearchParams = {
|
||||
category?: string | string[];
|
||||
collection?: string | string[];
|
||||
color?: string | string[];
|
||||
min?: string;
|
||||
max?: string;
|
||||
q?: string;
|
||||
page?: string;
|
||||
};
|
||||
|
||||
const PRODUCTS_PER_PAGE = 20;
|
||||
|
||||
function toArray(v?: string | string[]) {
|
||||
if (!v) return [];
|
||||
return Array.isArray(v) ? v : [v];
|
||||
}
|
||||
|
||||
export default async function ProductsPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const categoryRows = await prisma.category.findMany({
|
||||
where: { showOnPersonalised: true, parentId: null },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
children: {
|
||||
where: { showOnPersonalised: true },
|
||||
select: { id: true, name: true, slug: true },
|
||||
orderBy: { name: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
// Build flat list of all categories (parents and children) for filtering
|
||||
const allCategories = categoryRows.flatMap((parent) => [
|
||||
{ value: parent.slug, label: parent.name },
|
||||
...parent.children.map((child) => ({ value: child.slug, label: `${parent.name} > ${child.name}` })),
|
||||
]);
|
||||
const CATEGORIES = allCategories;
|
||||
|
||||
const collectionRows = await prisma.collection.findMany({
|
||||
select: { slug: true, name: true, group: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const OCCASIONS = collectionRows.filter((c) => c.group === 'OCCASION').map((c) => ({ value: c.slug, label: c.name }));
|
||||
const RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name }));
|
||||
|
||||
const selectedCategories = toArray(searchParams.category);
|
||||
const selectedCollections = toArray(searchParams.collection);
|
||||
const selectedColors = toArray(searchParams.color);
|
||||
const q = searchParams.q?.trim() ?? '';
|
||||
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
|
||||
const maxDollars = searchParams.max ? Number(searchParams.max) : undefined;
|
||||
const currentPage = Math.max(1, Number(searchParams.page) || 1);
|
||||
|
||||
// Cached palette across every personalised product
|
||||
const palette = await getPersonalisedPaletteColors();
|
||||
|
||||
const where: Prisma.ProductWhereInput = { showOnPersonalised: true };
|
||||
if (selectedCategories.length) {
|
||||
where.category = { in: selectedCategories };
|
||||
}
|
||||
if (selectedCollections.length) {
|
||||
where.collections = { some: { slug: { in: selectedCollections } } };
|
||||
}
|
||||
if (selectedColors.length) {
|
||||
where.AND = [{ OR: selectedColors.map((c) => ({ colors: { contains: c } })) }];
|
||||
}
|
||||
if (q) {
|
||||
where.name = { contains: q };
|
||||
}
|
||||
if (minDollars !== undefined || maxDollars !== undefined) {
|
||||
where.basePrice = {
|
||||
...(minDollars !== undefined ? { gte: Math.round(minDollars * 100) } : {}),
|
||||
...(maxDollars !== undefined ? { lte: Math.round(maxDollars * 100) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const { page } = await searchParams;
|
||||
const currentPage = Math.max(1, Number(page) || 1);
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
|
||||
const [rows, totalCount] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where,
|
||||
where: { showOnPersonalised: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: PRODUCTS_PER_PAGE,
|
||||
skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
|
||||
}),
|
||||
prisma.product.count({ where }),
|
||||
prisma.product.count({ where: { showOnPersonalised: true } }),
|
||||
]);
|
||||
|
||||
const products = rows.map((p) => toProductDTO(p, activePromotions, 'personalised'));
|
||||
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
|
||||
|
||||
const hasFilters =
|
||||
selectedCategories.length > 0 ||
|
||||
selectedCollections.length > 0 ||
|
||||
selectedColors.length > 0 ||
|
||||
q ||
|
||||
minDollars !== undefined ||
|
||||
maxDollars !== undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-12">
|
||||
<div className="mb-8 flex items-baseline justify-between">
|
||||
<div className="mb-12 flex items-baseline justify-between">
|
||||
<h1 className="font-display text-4xl">Shop all</h1>
|
||||
<p className="tag-label">
|
||||
{totalCount} template{totalCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
|
||||
{/* Sidebar filters */}
|
||||
<aside className="space-y-8">
|
||||
<div>
|
||||
<label htmlFor="q" className="tag-label mb-2 block">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
id="q"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search templates…"
|
||||
className="w-full border border-line px-3 py-2 text-sm focus:border-ink"
|
||||
/>
|
||||
<div>
|
||||
{products.length === 0 ? (
|
||||
<div className="border border-line bg-surface p-12 text-center">
|
||||
<p className="font-display text-xl">Nothing here yet</p>
|
||||
<p className="mt-2 text-sm text-muted">Templates will show up here once created.</p>
|
||||
</div>
|
||||
|
||||
) : (
|
||||
<div>
|
||||
<p className="tag-label mb-3">Category</p>
|
||||
<div className="space-y-2">
|
||||
{CATEGORIES.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="category"
|
||||
value={c.value}
|
||||
defaultChecked={selectedCategories.includes(c.value)}
|
||||
className="h-4 w-4 accent-splash-pink"
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{OCCASIONS.length > 0 && (
|
||||
<div>
|
||||
<p className="tag-label mb-3">Occasion</p>
|
||||
<div className="space-y-2">
|
||||
{OCCASIONS.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collection"
|
||||
value={c.value}
|
||||
defaultChecked={selectedCollections.includes(c.value)}
|
||||
className="h-4 w-4 accent-splash-pink"
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
|
||||
{currentPage > 1 && (
|
||||
<a
|
||||
href={`/made-to-order?page=${currentPage - 1}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
← Previous
|
||||
</a>
|
||||
)}
|
||||
<p className="text-sm text-muted">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
{currentPage < totalPages && (
|
||||
<a
|
||||
href={`/made-to-order?page=${currentPage + 1}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Next →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{RECIPIENTS.length > 0 && (
|
||||
<div>
|
||||
<p className="tag-label mb-3">Gifts for</p>
|
||||
<div className="space-y-2">
|
||||
{RECIPIENTS.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collection"
|
||||
value={c.value}
|
||||
defaultChecked={selectedCollections.includes(c.value)}
|
||||
className="h-4 w-4 accent-splash-pink"
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-3">Color</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{palette.map((hex) => {
|
||||
const checked = selectedColors.includes(hex);
|
||||
return (
|
||||
<label key={hex} className="cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="color"
|
||||
value={hex}
|
||||
defaultChecked={checked}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
title={hex}
|
||||
className={`block h-8 w-8 rounded-full border transition-transform peer-checked:scale-110 peer-checked:border-ink peer-checked:ring-2 peer-checked:ring-ink peer-checked:ring-offset-2 peer-checked:ring-offset-paper ${
|
||||
checked ? 'border-ink' : 'border-line'
|
||||
}`}
|
||||
style={{ backgroundColor: hex }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-3">Price</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
name="min"
|
||||
min={0}
|
||||
placeholder="Min"
|
||||
defaultValue={searchParams.min ?? ''}
|
||||
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
|
||||
/>
|
||||
<span className="text-muted">–</span>
|
||||
<input
|
||||
type="number"
|
||||
name="max"
|
||||
min={0}
|
||||
placeholder="Max"
|
||||
defaultValue={searchParams.max ?? ''}
|
||||
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Apply filters
|
||||
</button>
|
||||
{hasFilters && (
|
||||
<a href="/made-to-order" className="text-center text-sm text-muted hover:text-ink">
|
||||
Clear filters
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Grid */}
|
||||
<div>
|
||||
{products.length === 0 ? (
|
||||
<div className="border border-line bg-surface p-12 text-center">
|
||||
<p className="font-display text-xl">No templates match those filters</p>
|
||||
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
|
||||
{currentPage > 1 && (
|
||||
<a
|
||||
href={`/made-to-order?page=${currentPage - 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
← Previous
|
||||
</a>
|
||||
)}
|
||||
<p className="text-sm text-muted">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
{currentPage < totalPages && (
|
||||
<a
|
||||
href={`/made-to-order?page=${currentPage + 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Next →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+32
-261
@@ -1,120 +1,41 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { toProductDTO } from '@/lib/product';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
import { getPaletteColors } from '@/lib/cache';
|
||||
import ProductCard from '@/components/ProductCard';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
type SearchParams = {
|
||||
category?: string | string[];
|
||||
collection?: string | string[];
|
||||
color?: string | string[];
|
||||
min?: string;
|
||||
max?: string;
|
||||
q?: string;
|
||||
page?: string;
|
||||
};
|
||||
|
||||
const PRODUCTS_PER_PAGE = 20;
|
||||
|
||||
function toArray(v?: string | string[]) {
|
||||
if (!v) return [];
|
||||
return Array.isArray(v) ? v : [v];
|
||||
}
|
||||
|
||||
export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const categoryRows = await prisma.category.findMany({
|
||||
where: { showOnProducts: true, parentId: null },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
children: {
|
||||
where: { showOnProducts: true },
|
||||
select: { id: true, name: true, slug: true },
|
||||
orderBy: { name: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
// Build flat list of all categories (parents and children) for filtering
|
||||
const allCategories = categoryRows.flatMap((parent) => [
|
||||
{ value: parent.slug, label: parent.name },
|
||||
...parent.children.map((child) => ({ value: child.slug, label: `${parent.name} > ${child.name}` })),
|
||||
]);
|
||||
const CATEGORIES = allCategories;
|
||||
|
||||
const collectionRows = await prisma.collection.findMany({
|
||||
select: { slug: true, name: true, group: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const OCCASIONS = collectionRows.filter((c) => c.group === 'OCCASION').map((c) => ({ value: c.slug, label: c.name }));
|
||||
const RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name }));
|
||||
|
||||
const selectedCategories = toArray(searchParams.category);
|
||||
const selectedCollections = toArray(searchParams.collection);
|
||||
const selectedColors = toArray(searchParams.color);
|
||||
const q = searchParams.q?.trim() ?? '';
|
||||
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
|
||||
const maxDollars = searchParams.max ? Number(searchParams.max) : undefined;
|
||||
const currentPage = Math.max(1, Number(searchParams.page) || 1);
|
||||
|
||||
// Cached palette across ready-made products for the swatch filter
|
||||
const palette = await getPaletteColors();
|
||||
|
||||
const where: Prisma.ProductWhereInput = { showAsReadyMade: true };
|
||||
if (selectedCategories.length) {
|
||||
where.category = { in: selectedCategories };
|
||||
}
|
||||
if (selectedCollections.length) {
|
||||
where.collections = { some: { slug: { in: selectedCollections } } };
|
||||
}
|
||||
if (selectedColors.length) {
|
||||
where.AND = [{ OR: selectedColors.map((c) => ({ colors: { contains: c } })) }];
|
||||
}
|
||||
if (q) {
|
||||
where.name = { contains: q };
|
||||
}
|
||||
if (minDollars !== undefined || maxDollars !== undefined) {
|
||||
where.basePrice = {
|
||||
...(minDollars !== undefined ? { gte: Math.round(minDollars * 100) } : {}),
|
||||
...(maxDollars !== undefined ? { lte: Math.round(maxDollars * 100) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
|
||||
const [rows, totalCount] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where,
|
||||
where: { showAsReadyMade: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: PRODUCTS_PER_PAGE,
|
||||
skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
|
||||
}),
|
||||
prisma.product.count({ where }),
|
||||
prisma.product.count({ where: { showAsReadyMade: true } }),
|
||||
]);
|
||||
|
||||
const products = rows.map((p) => toProductDTO(p, activePromotions, 'plain'));
|
||||
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
|
||||
|
||||
const hasFilters =
|
||||
selectedCategories.length > 0 ||
|
||||
selectedCollections.length > 0 ||
|
||||
selectedColors.length > 0 ||
|
||||
q ||
|
||||
minDollars !== undefined ||
|
||||
maxDollars !== undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-12">
|
||||
<div className="mb-8 flex items-baseline justify-between">
|
||||
<div className="mb-12 flex items-baseline justify-between">
|
||||
<h1 className="font-display text-4xl">Products</h1>
|
||||
<p className="tag-label">
|
||||
{products.length} item{products.length === 1 ? '' : 's'}
|
||||
{totalCount} item{totalCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{products.length === 0 && !hasFilters ? (
|
||||
{products.length === 0 ? (
|
||||
<div className="border border-line bg-surface p-12 text-center">
|
||||
<p className="font-display text-xl">Nothing here yet</p>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
@@ -123,187 +44,37 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
|
||||
{/* Sidebar filters */}
|
||||
<aside className="space-y-8">
|
||||
<div>
|
||||
<label htmlFor="q" className="tag-label mb-2 block">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
id="q"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search products…"
|
||||
className="w-full border border-line px-3 py-2 text-sm focus:border-ink"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((p) => (
|
||||
<ProductCard key={p.id} product={p} hrefBase="/products" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-3">Category</p>
|
||||
<div className="space-y-2">
|
||||
{CATEGORIES.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="category"
|
||||
value={c.value}
|
||||
defaultChecked={selectedCategories.includes(c.value)}
|
||||
className="h-4 w-4 accent-splash-pink"
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{OCCASIONS.length > 0 && (
|
||||
<div>
|
||||
<p className="tag-label mb-3">Occasion</p>
|
||||
<div className="space-y-2">
|
||||
{OCCASIONS.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collection"
|
||||
value={c.value}
|
||||
defaultChecked={selectedCollections.includes(c.value)}
|
||||
className="h-4 w-4 accent-splash-pink"
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{RECIPIENTS.length > 0 && (
|
||||
<div>
|
||||
<p className="tag-label mb-3">Gifts for</p>
|
||||
<div className="space-y-2">
|
||||
{RECIPIENTS.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collection"
|
||||
value={c.value}
|
||||
defaultChecked={selectedCollections.includes(c.value)}
|
||||
className="h-4 w-4 accent-splash-pink"
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-3">Color</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{palette.map((hex) => {
|
||||
const checked = selectedColors.includes(hex);
|
||||
return (
|
||||
<label key={hex} className="cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="color"
|
||||
value={hex}
|
||||
defaultChecked={checked}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
title={hex}
|
||||
className={`block h-8 w-8 rounded-full border transition-transform peer-checked:scale-110 peer-checked:border-ink peer-checked:ring-2 peer-checked:ring-ink peer-checked:ring-offset-2 peer-checked:ring-offset-paper ${
|
||||
checked ? 'border-ink' : 'border-line'
|
||||
}`}
|
||||
style={{ backgroundColor: hex }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-3">Price</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
name="min"
|
||||
min={0}
|
||||
placeholder="Min"
|
||||
defaultValue={searchParams.min ?? ''}
|
||||
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
|
||||
/>
|
||||
<span className="text-muted">–</span>
|
||||
<input
|
||||
type="number"
|
||||
name="max"
|
||||
min={0}
|
||||
placeholder="Max"
|
||||
defaultValue={searchParams.max ?? ''}
|
||||
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Apply filters
|
||||
</button>
|
||||
{hasFilters && (
|
||||
<a href="/products" className="text-center text-sm text-muted hover:text-ink">
|
||||
Clear filters
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
|
||||
{currentPage > 1 && (
|
||||
<a
|
||||
href={`/products?page=${currentPage - 1}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
← Previous
|
||||
</a>
|
||||
)}
|
||||
<p className="text-sm text-muted">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
{currentPage < totalPages && (
|
||||
<a
|
||||
href={`/products?page=${currentPage + 1}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Next →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Grid */}
|
||||
<div>
|
||||
{products.length === 0 ? (
|
||||
<div className="border border-line bg-surface p-12 text-center">
|
||||
<p className="font-display text-xl">No products match those filters</p>
|
||||
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((p) => (
|
||||
<ProductCard key={p.id} product={p} hrefBase="/products" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
|
||||
{currentPage > 1 && (
|
||||
<a
|
||||
href={`/products?page=${currentPage - 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
← Previous
|
||||
</a>
|
||||
)}
|
||||
<p className="text-sm text-muted">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
{currentPage < totalPages && (
|
||||
<a
|
||||
href={`/products?page=${currentPage + 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Next →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Stage, Layer, Text, Image as KonvaImage, Transformer } from 'react-konva';
|
||||
import type Konva from 'konva';
|
||||
import Konva from 'konva';
|
||||
import type { DesignElement } from '@/lib/types';
|
||||
|
||||
interface AdminDesignEditorProps {
|
||||
|
||||
@@ -26,11 +26,13 @@ export default function ApproveSection({ proof }: { proof: ProofDTO }) {
|
||||
size: null,
|
||||
quantity: proof.quantity,
|
||||
unitPrice: proof.unitPrice,
|
||||
designJson: { front: [], back: [] },
|
||||
designJson: { front: [], back: [] } as any,
|
||||
designPreviewUrl: proof.imageDataUrl,
|
||||
designPreviewUrlBack: null,
|
||||
placementPreviewUrl: proof.imageDataUrl,
|
||||
placementPreviewUrlBack: null,
|
||||
designWidthCm: null,
|
||||
designHeightCm: null,
|
||||
});
|
||||
setStatus('APPROVED');
|
||||
router.push('/cart');
|
||||
|
||||
@@ -325,12 +325,13 @@ function PrintSurface({
|
||||
/>
|
||||
{elements.map((el) =>
|
||||
el.type === 'text' ? (
|
||||
el.curvedPath ? (
|
||||
(el as any).curvedPath ? (
|
||||
// Curved text - TextPath with dragging via offset
|
||||
(() => {
|
||||
const elAny = el as any;
|
||||
const offsetX = el.x - 280;
|
||||
const offsetY = el.y - 280;
|
||||
const pathData = generateCurvePath(el.curvedPath.type, el.curvedPath.radius, el.curvedPath.reverse, offsetX, offsetY);
|
||||
const pathData = generateCurvePath(elAny.curvedPath.type, elAny.curvedPath.radius, elAny.curvedPath.reverse, offsetX, offsetY);
|
||||
console.log(`🔄 Rendering curved text ${el.text}: position (${el.x}, ${el.y}), offset (${offsetX}, ${offsetY})`);
|
||||
return (
|
||||
<TextPath
|
||||
@@ -773,10 +774,10 @@ export default function DesignCanvas({
|
||||
salePrice: product.onSale ? product.effectivePrice : null,
|
||||
saleStartsAt: null,
|
||||
saleEndsAt: null,
|
||||
},
|
||||
} as any,
|
||||
[],
|
||||
new Date(),
|
||||
true,
|
||||
'personalised',
|
||||
).price
|
||||
: product.effectivePrice;
|
||||
|
||||
@@ -1251,10 +1252,10 @@ export default function DesignCanvas({
|
||||
salePrice: product.onSale ? product.effectivePrice : null,
|
||||
saleStartsAt: null,
|
||||
saleEndsAt: null,
|
||||
},
|
||||
} as any,
|
||||
[],
|
||||
new Date(),
|
||||
true,
|
||||
'personalised',
|
||||
);
|
||||
return (
|
||||
<>
|
||||
@@ -1493,20 +1494,24 @@ export default function DesignCanvas({
|
||||
<label className="flex items-center gap-2 text-sm text-muted mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={text.curvedPath ? true : false}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null })}
|
||||
checked={(text as any).curvedPath ? true : false}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null } as any)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
Curved text
|
||||
</label>
|
||||
|
||||
{text.curvedPath && (
|
||||
{(text as any).curvedPath && (
|
||||
<div className="space-y-3">
|
||||
{(() => {
|
||||
const textAny = text as any;
|
||||
return (
|
||||
<>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted">Curve type</span>
|
||||
<select
|
||||
value={text.curvedPath.type || 'circle'}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, type: e.target.value as any } })}
|
||||
value={textAny.curvedPath.type || 'circle'}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, type: e.target.value as any } } as any)}
|
||||
className="border border-line bg-paper px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="circle">Circle</option>
|
||||
@@ -1523,23 +1528,26 @@ export default function DesignCanvas({
|
||||
type="range"
|
||||
min={20}
|
||||
max={200}
|
||||
value={text.curvedPath.radius || 80}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, radius: Number(e.target.value) } })}
|
||||
value={textAny.curvedPath.radius || 80}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, radius: Number(e.target.value) } } as any)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-12 text-right font-mono text-xs">{text.curvedPath.radius || 80}</span>
|
||||
<span className="w-12 text-right font-mono text-xs">{textAny.curvedPath.radius || 80}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={text.curvedPath.reverse ? true : false}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, reverse: e.target.checked } })}
|
||||
checked={textAny.curvedPath.reverse ? true : false}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, reverse: e.target.checked } } as any)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-muted">Reverse direction</span>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
class DesignCanvasErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center rounded-lg border border-line bg-paper">
|
||||
<div className="text-center">
|
||||
<p className="mb-2 font-medium text-ink">Design Canvas Loading...</p>
|
||||
<p className="text-sm text-muted">Please refresh the page if this persists</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default DesignCanvasErrorBoundary;
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import FabricDesignCanvasV2 from './FabricDesignCanvasV2';
|
||||
import type { ProductDTO } from '@/lib/types';
|
||||
|
||||
interface DesignCanvasWrapperProps {
|
||||
product: ProductDTO;
|
||||
customPhoto?: string;
|
||||
}
|
||||
|
||||
export default function DesignCanvasWrapper({ product, customPhoto }: DesignCanvasWrapperProps) {
|
||||
return <FabricDesignCanvasV2 product={product} customPhoto={customPhoto} />;
|
||||
}
|
||||
@@ -0,0 +1,881 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import ProductMockup from './ProductMockup';
|
||||
import Price from './Price';
|
||||
import LoginPromptModal from './LoginPromptModal';
|
||||
import { useCart } from '@/lib/cartStore';
|
||||
import { getEffectivePrice } from '@/lib/pricing';
|
||||
import { checkCustomerAuth } from '@/app/actions';
|
||||
import type { DesignElement, ProductDTO } from '@/lib/types';
|
||||
|
||||
const DISPLAY = 560;
|
||||
const FONTS = ['Arial', 'Georgia', 'Courier New', 'Times New Roman', 'Verdana', 'Comic Sans MS'];
|
||||
|
||||
interface DesignCanvasProps {
|
||||
product: ProductDTO;
|
||||
customPhoto?: string;
|
||||
}
|
||||
|
||||
export default function FabricDesignCanvas({ product, customPhoto }: DesignCanvasProps) {
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
||||
const frontCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const backCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const measureCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [design, setDesign] = useState<{ front: DesignElement[]; back: DesignElement[] }>({
|
||||
front: [],
|
||||
back: [],
|
||||
});
|
||||
const [side, setSide] = useState<'front' | 'back'>('front');
|
||||
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [selectedColor, setSelectedColor] = useState<string>(product.colors[0]);
|
||||
const [dragging, setDragging] = useState<{
|
||||
id: string;
|
||||
type: 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se';
|
||||
startX: number;
|
||||
startY: number;
|
||||
elementX: number;
|
||||
elementY: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
} | null>(null);
|
||||
|
||||
const { addItem } = useCart();
|
||||
|
||||
// Helper to measure text width
|
||||
const measureTextWidth = (text: string, fontSize: number, fontFamily: string): number => {
|
||||
if (!measureCanvasRef.current) {
|
||||
measureCanvasRef.current = document.createElement('canvas');
|
||||
}
|
||||
const ctx = measureCanvasRef.current.getContext('2d');
|
||||
if (!ctx) return text.length * (fontSize * 0.6); // Fallback estimate
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
return ctx.measureText(text).width;
|
||||
};
|
||||
|
||||
// Validate element stays within design area
|
||||
const validateElementBounds = useCallback((element: DesignElement, printArea: any): DesignElement => {
|
||||
const minX = Math.round(printArea.xPct * DISPLAY);
|
||||
const minY = Math.round(printArea.yPct * DISPLAY);
|
||||
const maxX = minX + Math.round(printArea.wPct * DISPLAY);
|
||||
const maxY = minY + Math.round(printArea.hPct * DISPLAY);
|
||||
|
||||
const elementWidth = element.type === 'text' ? 150 : element.width;
|
||||
const elementHeight = element.type === 'text' ? element.fontSize : element.height;
|
||||
|
||||
return {
|
||||
...element,
|
||||
x: Math.max(minX, Math.min(element.x, maxX - elementWidth)),
|
||||
y: Math.max(minY, Math.min(element.y, maxY - elementHeight)),
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset design state when product changes
|
||||
useEffect(() => {
|
||||
setDesign({ front: [], back: [] });
|
||||
setSide('front');
|
||||
setSelectedId(null);
|
||||
setPendingId(null);
|
||||
}, [product.id]);
|
||||
|
||||
// Clear selection and pending when switching views
|
||||
useEffect(() => {
|
||||
setSelectedId(null);
|
||||
setPendingId(null);
|
||||
}, [side]);
|
||||
|
||||
// Redraw canvas whenever design, selection, or dragging changes (for dynamic updates)
|
||||
useEffect(() => {
|
||||
const canvas = side === 'front' ? frontCanvasRef.current : backCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Get print area and validate all elements before drawing
|
||||
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
|
||||
const elements = side === 'front' ? design.front : design.back;
|
||||
|
||||
elements.forEach((el) => {
|
||||
// Validate element bounds before drawing
|
||||
const validatedEl = validateElementBounds(el, printArea);
|
||||
if (validatedEl.type === 'text') {
|
||||
ctx.font = `${validatedEl.fontSize}px ${validatedEl.fontFamily}`;
|
||||
ctx.fillStyle = validatedEl.fill;
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.save();
|
||||
ctx.translate(validatedEl.x, validatedEl.y);
|
||||
ctx.rotate(((validatedEl.rotation || 0) * Math.PI) / 180);
|
||||
ctx.fillText(validatedEl.text, 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
// Draw selection box and resize handles if selected
|
||||
if (selectedId === validatedEl.id) {
|
||||
const textWidth = ctx.measureText(validatedEl.text).width;
|
||||
const textHeight = validatedEl.fontSize;
|
||||
|
||||
ctx.strokeStyle = '#FF6B6B';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(validatedEl.x - 5, validatedEl.y - 5, textWidth + 10, textHeight + 10);
|
||||
|
||||
// Draw resize handles
|
||||
const handleSize = 8;
|
||||
const handles = [
|
||||
{ x: validatedEl.x - handleSize / 2, y: validatedEl.y - handleSize / 2 }, // top-left
|
||||
{ x: validatedEl.x + textWidth + handleSize / 2, y: validatedEl.y - handleSize / 2 }, // top-right
|
||||
{ x: validatedEl.x - handleSize / 2, y: validatedEl.y + textHeight + handleSize / 2 }, // bottom-left
|
||||
{ x: validatedEl.x + textWidth + handleSize / 2, y: validatedEl.y + textHeight + handleSize / 2 }, // bottom-right
|
||||
];
|
||||
|
||||
handles.forEach((handle) => {
|
||||
ctx.fillStyle = '#FF6B6B';
|
||||
ctx.fillRect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize);
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize);
|
||||
});
|
||||
}
|
||||
} else if (validatedEl.type === 'image') {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.save();
|
||||
ctx.translate(validatedEl.x, validatedEl.y);
|
||||
ctx.rotate(((validatedEl.rotation || 0) * Math.PI) / 180);
|
||||
ctx.drawImage(img, 0, 0, validatedEl.width, validatedEl.height);
|
||||
|
||||
// Draw selection box if selected
|
||||
if (selectedId === validatedEl.id) {
|
||||
ctx.strokeStyle = '#FF6B6B';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(-5, -5, validatedEl.width + 10, validatedEl.height + 10);
|
||||
|
||||
// Draw resize handles
|
||||
const handleSize = 8;
|
||||
const handles = [
|
||||
{ x: -handleSize / 2, y: -handleSize / 2 }, // top-left
|
||||
{ x: validatedEl.width + handleSize / 2, y: -handleSize / 2 }, // top-right
|
||||
{ x: -handleSize / 2, y: validatedEl.height + handleSize / 2 }, // bottom-left
|
||||
{ x: validatedEl.width + handleSize / 2, y: validatedEl.height + handleSize / 2 }, // bottom-right
|
||||
];
|
||||
|
||||
handles.forEach((handle) => {
|
||||
ctx.fillStyle = '#FF6B6B';
|
||||
ctx.fillRect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize);
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
img.src = validatedEl.src;
|
||||
}
|
||||
});
|
||||
}, [design, side, selectedId, product, dragging]);
|
||||
|
||||
const addTextElement = useCallback(() => {
|
||||
const id = uuid();
|
||||
|
||||
// Calculate initial position centered in the print area
|
||||
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
|
||||
const minX = Math.round(printArea.xPct * DISPLAY);
|
||||
const minY = Math.round(printArea.yPct * DISPLAY);
|
||||
const printWidth = Math.round(printArea.wPct * DISPLAY);
|
||||
const printHeight = Math.round(printArea.hPct * DISPLAY);
|
||||
|
||||
// Measure actual text width
|
||||
const fontSize = 24;
|
||||
const textWidth = measureTextWidth('New Text', fontSize, 'Arial');
|
||||
|
||||
// Center horizontally and vertically in print area
|
||||
const centerX = minX + Math.round((printWidth - textWidth) / 2);
|
||||
const centerY = minY + Math.round((printHeight - fontSize) / 2);
|
||||
|
||||
const newElement: DesignElement = {
|
||||
id,
|
||||
type: 'text',
|
||||
text: 'New Text',
|
||||
x: Math.max(minX, centerX),
|
||||
y: Math.max(minY, centerY),
|
||||
fontSize,
|
||||
fontFamily: 'Arial',
|
||||
fill: '#000000',
|
||||
rotation: 0,
|
||||
};
|
||||
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
[side]: [...prev[side], newElement],
|
||||
}));
|
||||
setSelectedId(id);
|
||||
setPendingId(id);
|
||||
}, [side, product]);
|
||||
|
||||
const updateElement = useCallback(
|
||||
(id: string, updates: Partial<DesignElement>) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
[side]: prev[side].map((el) =>
|
||||
el.id === id ? { ...el, ...updates } : el
|
||||
),
|
||||
}));
|
||||
},
|
||||
[side]
|
||||
);
|
||||
|
||||
const addImageElement = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
const id = uuid();
|
||||
const newElement: DesignElement = {
|
||||
id,
|
||||
type: 'image',
|
||||
src: evt.target?.result as string,
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
rotation: 0,
|
||||
};
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
[side]: [...prev[side], newElement],
|
||||
}));
|
||||
setSelectedId(id);
|
||||
setPendingId(id);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}, [side]);
|
||||
|
||||
const confirmElement = useCallback(() => {
|
||||
setPendingId(null);
|
||||
}, []);
|
||||
|
||||
const cancelElement = useCallback(() => {
|
||||
if (!pendingId) return;
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
[side]: prev[side].filter((el) => el.id !== pendingId),
|
||||
}));
|
||||
setSelectedId(null);
|
||||
setPendingId(null);
|
||||
}, [pendingId, side]);
|
||||
|
||||
const deleteElement = useCallback(
|
||||
(id: string) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
[side]: prev[side].filter((el) => el.id !== id),
|
||||
}));
|
||||
setSelectedId(null);
|
||||
},
|
||||
[side]
|
||||
);
|
||||
|
||||
const handleCanvasClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = e.currentTarget;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const elements = side === 'front' ? design.front : design.back;
|
||||
let clicked = false;
|
||||
|
||||
for (const el of elements) {
|
||||
if (el.type === 'text') {
|
||||
if (x >= el.x && x <= el.x + 200 && y >= el.y && y <= el.y + el.fontSize + 10) {
|
||||
setSelectedId(el.id);
|
||||
setPendingId(null);
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
} else if (el.type === 'image') {
|
||||
if (x >= el.x && x <= el.x + el.width && y >= el.y && y <= el.y + el.height) {
|
||||
setSelectedId(el.id);
|
||||
setPendingId(null);
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!clicked) {
|
||||
setSelectedId(null);
|
||||
setPendingId(null);
|
||||
}
|
||||
},
|
||||
[design, side]
|
||||
);
|
||||
|
||||
const handleCanvasMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!selectedId) return;
|
||||
|
||||
const canvas = e.currentTarget;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const clickY = e.clientY - rect.top;
|
||||
|
||||
const elements = side === 'front' ? design.front : design.back;
|
||||
const element = elements.find((el) => el.id === selectedId);
|
||||
if (!element) return;
|
||||
|
||||
// Determine if clicking on a resize handle or the element itself
|
||||
let dragType: 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se' = 'move';
|
||||
|
||||
if (element.type === 'text') {
|
||||
const textWidth = measureTextWidth(element.text, element.fontSize, element.fontFamily);
|
||||
const textHeight = element.fontSize;
|
||||
const handleSize = 8;
|
||||
|
||||
// Check if click is within handleSize of each corner
|
||||
const nearTopLeft = Math.abs(clickX - element.x) < handleSize && Math.abs(clickY - element.y) < handleSize;
|
||||
const nearTopRight = Math.abs(clickX - (element.x + textWidth)) < handleSize && Math.abs(clickY - element.y) < handleSize;
|
||||
const nearBottomLeft = Math.abs(clickX - element.x) < handleSize && Math.abs(clickY - (element.y + textHeight)) < handleSize;
|
||||
const nearBottomRight = Math.abs(clickX - (element.x + textWidth)) < handleSize && Math.abs(clickY - (element.y + textHeight)) < handleSize;
|
||||
|
||||
if (nearTopLeft) dragType = 'resize-nw';
|
||||
else if (nearTopRight) dragType = 'resize-ne';
|
||||
else if (nearBottomLeft) dragType = 'resize-sw';
|
||||
else if (nearBottomRight) dragType = 'resize-se';
|
||||
} else if (element.type === 'image') {
|
||||
const handleSize = 10;
|
||||
|
||||
// Check if click is within handleSize of each corner
|
||||
const nearTopLeft = Math.abs(clickX - element.x) < handleSize && Math.abs(clickY - element.y) < handleSize;
|
||||
const nearTopRight = Math.abs(clickX - (element.x + element.width)) < handleSize && Math.abs(clickY - element.y) < handleSize;
|
||||
const nearBottomLeft = Math.abs(clickX - element.x) < handleSize && Math.abs(clickY - (element.y + element.height)) < handleSize;
|
||||
const nearBottomRight = Math.abs(clickX - (element.x + element.width)) < handleSize && Math.abs(clickY - (element.y + element.height)) < handleSize;
|
||||
|
||||
if (nearTopLeft) dragType = 'resize-nw';
|
||||
else if (nearTopRight) dragType = 'resize-ne';
|
||||
else if (nearBottomLeft) dragType = 'resize-sw';
|
||||
else if (nearBottomRight) dragType = 'resize-se';
|
||||
}
|
||||
|
||||
setDragging({
|
||||
id: selectedId,
|
||||
type: dragType,
|
||||
startX: clickX,
|
||||
startY: clickY,
|
||||
elementX: element.x,
|
||||
elementY: element.y,
|
||||
offsetX: element.x - clickX,
|
||||
offsetY: element.y - clickY,
|
||||
});
|
||||
},
|
||||
[selectedId, side, design]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!dragging) return;
|
||||
|
||||
const canvas = side === 'front' ? frontCanvasRef.current : backCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const currentX = e.clientX - rect.left;
|
||||
const currentY = e.clientY - rect.top;
|
||||
|
||||
const elements = side === 'front' ? design.front : design.back;
|
||||
const element = elements.find((el) => el.id === dragging.id);
|
||||
|
||||
if (element) {
|
||||
// Calculate print area bounds
|
||||
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
|
||||
const minX = Math.round(printArea.xPct * DISPLAY);
|
||||
const minY = Math.round(printArea.yPct * DISPLAY);
|
||||
const maxX = minX + Math.round(printArea.wPct * DISPLAY);
|
||||
const maxY = minY + Math.round(printArea.hPct * DISPLAY);
|
||||
const printAreaWidth = maxX - minX;
|
||||
const printAreaHeight = maxY - minY;
|
||||
|
||||
if (dragging.type === 'move') {
|
||||
// Position element based on initial element position + mouse delta
|
||||
const dx = currentX - dragging.startX;
|
||||
const dy = currentY - dragging.startY;
|
||||
|
||||
let newX = dragging.elementX + dx;
|
||||
let newY = dragging.elementY + dy;
|
||||
|
||||
// Constrain to print area boundaries - ensure element stays fully inside
|
||||
let elementWidth = element.width || 0;
|
||||
let elementHeight = element.height || element.fontSize || 0;
|
||||
|
||||
// For text, measure actual width
|
||||
if (element.type === 'text') {
|
||||
elementWidth = measureTextWidth(element.text, element.fontSize, element.fontFamily) + 10;
|
||||
elementHeight = element.fontSize * 1.2; // Account for line height
|
||||
}
|
||||
|
||||
// Constrain to print area - allow some overflow if element is too large
|
||||
// X-axis: keep left edge from going too far left, but allow right to extend
|
||||
const maxLeftX = maxX - 20; // Allow small overlap on right
|
||||
newX = Math.max(minX - elementWidth + 20, Math.min(newX, maxLeftX));
|
||||
|
||||
// Y-axis: similar approach
|
||||
const maxTopY = maxY - 10;
|
||||
newY = Math.max(minY - elementHeight + 10, Math.min(newY, maxTopY));
|
||||
|
||||
updateElement(dragging.id, { x: newX, y: newY });
|
||||
} else if (element.type === 'text') {
|
||||
// Resize text by changing font size based on vertical drag
|
||||
const dy = currentY - dragging.startY;
|
||||
const newSize = Math.max(8, Math.min(72, element.fontSize + Math.round(dy / 10)));
|
||||
updateElement(dragging.id, { fontSize: newSize });
|
||||
} else if (element.type === 'image') {
|
||||
// Resize image from corners with proper boundary handling
|
||||
const dx = currentX - dragging.startX;
|
||||
const dy = currentY - dragging.startY;
|
||||
|
||||
let newX = element.x;
|
||||
let newY = element.y;
|
||||
let newWidth = element.width;
|
||||
let newHeight = element.height;
|
||||
|
||||
// Handle corner-specific resize logic
|
||||
if (dragging.type === 'resize-se') {
|
||||
// Southeast: expand right and down
|
||||
newWidth = Math.max(20, element.width + Math.round(dx / 2));
|
||||
newHeight = Math.max(20, element.height + Math.round(dy / 2));
|
||||
} else if (dragging.type === 'resize-sw') {
|
||||
// Southwest: expand left and down
|
||||
newX = element.x + Math.round(dx / 2);
|
||||
newWidth = Math.max(20, element.width - Math.round(dx / 2));
|
||||
newHeight = Math.max(20, element.height + Math.round(dy / 2));
|
||||
} else if (dragging.type === 'resize-ne') {
|
||||
// Northeast: expand right and up
|
||||
newY = element.y + Math.round(dy / 2);
|
||||
newWidth = Math.max(20, element.width + Math.round(dx / 2));
|
||||
newHeight = Math.max(20, element.height - Math.round(dy / 2));
|
||||
} else if (dragging.type === 'resize-nw') {
|
||||
// Northwest: expand left and up
|
||||
newX = element.x + Math.round(dx / 2);
|
||||
newY = element.y + Math.round(dy / 2);
|
||||
newWidth = Math.max(20, element.width - Math.round(dx / 2));
|
||||
newHeight = Math.max(20, element.height - Math.round(dy / 2));
|
||||
}
|
||||
|
||||
// Constrain to print area boundaries
|
||||
newX = Math.max(minX, Math.min(newX, maxX - 20));
|
||||
newY = Math.max(minY, Math.min(newY, maxY - 20));
|
||||
newWidth = Math.min(newWidth, maxX - newX);
|
||||
newHeight = Math.min(newHeight, maxY - newY);
|
||||
|
||||
updateElement(dragging.id, { x: newX, y: newY, width: newWidth, height: newHeight });
|
||||
}
|
||||
}
|
||||
},
|
||||
[dragging, side, design, product, updateElement]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setDragging(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [dragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const selectedElement = selectedId
|
||||
? (side === 'front' ? design.front : design.back).find((el) => el.id === selectedId)
|
||||
: null;
|
||||
|
||||
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
|
||||
const canvasW = Math.round(printArea.wPct * DISPLAY);
|
||||
const canvasH = Math.round(printArea.hPct * DISPLAY);
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
const customer = await checkCustomerAuth();
|
||||
if (!customer) {
|
||||
setShowLoginPrompt(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const priceInfo = getEffectivePrice(product, [], new Date(), 'personalised');
|
||||
addItem({
|
||||
productId: product.id,
|
||||
quantity: 1,
|
||||
designJson: JSON.stringify(design),
|
||||
designWidthCm: product.referenceWidthCm || null,
|
||||
designHeightCm: product.referenceHeightCm || null,
|
||||
price: priceInfo.price,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{showLoginPrompt && <LoginPromptModal onClose={() => setShowLoginPrompt(false)} />}
|
||||
|
||||
<div className="p-6 bg-paper rounded-lg border border-line">
|
||||
<h3 className="text-lg font-medium mb-4">Design Canvas</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Canvas */}
|
||||
<div className="col-span-2">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSide('front');
|
||||
setSelectedId(null);
|
||||
}}
|
||||
className={`px-4 py-2 rounded ${side === 'front' ? 'bg-clay text-paper' : 'bg-line text-ink'}`}
|
||||
>
|
||||
Front
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSide('back');
|
||||
setSelectedId(null);
|
||||
}}
|
||||
className={`px-4 py-2 rounded ${side === 'back' ? 'bg-clay text-paper' : 'bg-line text-ink'}`}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Color Picker */}
|
||||
{product.colors.length > 1 && (
|
||||
<div className="mt-4 flex gap-2 items-center">
|
||||
<span className="text-sm font-medium">Garment Color:</span>
|
||||
<div className="flex gap-2">
|
||||
{product.colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setSelectedColor(color)}
|
||||
className={`w-8 h-8 rounded border-2 transition-all ${
|
||||
selectedColor === color
|
||||
? 'border-clay ring-2 ring-clay'
|
||||
: 'border-line hover:border-clay'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
aria-label={`Color ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={canvasContainerRef} className="bg-surface border border-line rounded p-4 relative inline-block mt-4">
|
||||
<div
|
||||
style={{
|
||||
width: DISPLAY,
|
||||
height: DISPLAY,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Product image - prioritize colorPhotos over imageUrl */}
|
||||
{customPhoto && side === 'front' ? (
|
||||
<img
|
||||
src={customPhoto}
|
||||
alt="Product front"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : side === 'front' && product.colorPhotos?.[selectedColor] && product.colorPhotos[selectedColor] !== product.imageUrl ? (
|
||||
<img
|
||||
src={product.colorPhotos[selectedColor]}
|
||||
alt="Product front"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : side === 'front' && product.imageUrl ? (
|
||||
<img
|
||||
src={product.imageUrl}
|
||||
alt="Product front"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : side === 'back' && product.colorPhotosBack?.[selectedColor] ? (
|
||||
<img
|
||||
src={product.colorPhotosBack[selectedColor]}
|
||||
alt="Product back"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : side === 'back' && product.imageUrlBack && product.imageUrlBack !== product.imageUrl ? (
|
||||
<img
|
||||
src={product.imageUrlBack}
|
||||
alt="Product back"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : side === 'back' && product.colorPhotos?.[selectedColor] ? (
|
||||
<img
|
||||
src={product.colorPhotos[selectedColor]}
|
||||
alt="Product back (using front color photo)"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : side === 'back' && product.imageUrl ? (
|
||||
<img
|
||||
src={product.imageUrl}
|
||||
alt="Product back (using front image)"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ProductMockup mockup={product.mockup} color={selectedColor} />
|
||||
)}
|
||||
|
||||
{/* Print area guide */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${printArea.yPct * DISPLAY}px`,
|
||||
left: `${printArea.xPct * DISPLAY}px`,
|
||||
width: `${printArea.wPct * DISPLAY}px`,
|
||||
height: `${printArea.hPct * DISPLAY}px`,
|
||||
border: '2px dashed #E5227E',
|
||||
boxShadow: 'inset 0 0 0 1px rgba(229, 34, 126, 0.3)',
|
||||
backgroundColor: 'rgba(229, 34, 126, 0.02)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Front Canvas */}
|
||||
{side === 'front' && (
|
||||
<canvas
|
||||
ref={frontCanvasRef}
|
||||
width={canvasW}
|
||||
height={canvasH}
|
||||
onClick={handleCanvasClick}
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${printArea.yPct * DISPLAY}px`,
|
||||
left: `${printArea.xPct * DISPLAY}px`,
|
||||
backgroundColor: 'transparent',
|
||||
cursor: selectedId ? 'grab' : 'pointer',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Back Canvas */}
|
||||
{side === 'back' && (
|
||||
<canvas
|
||||
ref={backCanvasRef}
|
||||
width={canvasW}
|
||||
height={canvasH}
|
||||
onClick={handleCanvasClick}
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${printArea.yPct * DISPLAY}px`,
|
||||
left: `${printArea.xPct * DISPLAY}px`,
|
||||
backgroundColor: 'transparent',
|
||||
cursor: selectedId ? 'grab' : 'pointer',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={addTextElement}
|
||||
className="px-4 py-2 bg-clay text-paper rounded hover:bg-clay-dark"
|
||||
>
|
||||
Add Text
|
||||
</button>
|
||||
<button
|
||||
onClick={addImageElement}
|
||||
className="px-4 py-2 bg-clay text-paper rounded hover:bg-clay-dark"
|
||||
>
|
||||
Add Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Properties Panel */}
|
||||
<div className="bg-line rounded p-4 space-y-4 h-fit">
|
||||
<div className="text-sm font-medium">Properties</div>
|
||||
|
||||
{selectedElement ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-xs font-medium">Element Type</label>
|
||||
<div className="text-xs text-muted capitalize mt-1">
|
||||
{selectedElement.type === 'text' ? 'Text' : 'Image'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedElement.type === 'text' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-xs font-medium">Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedElement.text}
|
||||
onChange={(e) => updateElement(selectedElement.id, { text: e.target.value })}
|
||||
className="w-full text-xs border border-line rounded px-2 py-1 mt-1 text-ink"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium">Font</label>
|
||||
<select
|
||||
value={selectedElement.fontFamily}
|
||||
onChange={(e) => updateElement(selectedElement.id, { fontFamily: e.target.value })}
|
||||
className="w-full text-xs border border-line rounded px-2 py-1 mt-1 text-ink"
|
||||
>
|
||||
{FONTS.map((font) => (
|
||||
<option key={font} value={font}>
|
||||
{font}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium">Size</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
value={selectedElement.fontSize}
|
||||
onChange={(e) => updateElement(selectedElement.id, { fontSize: parseInt(e.target.value) })}
|
||||
className="flex-1 text-xs border border-line rounded px-2 py-1 text-ink"
|
||||
min="8"
|
||||
max="72"
|
||||
/>
|
||||
<span className="text-xs text-muted self-center">px</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted mt-1">Drag corners on canvas to resize</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium">Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={selectedElement.fill}
|
||||
onChange={(e) => updateElement(selectedElement.id, { fill: e.target.value })}
|
||||
className="w-full h-8 border border-line rounded mt-1 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedElement.type === 'image' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-xs font-medium">Width</label>
|
||||
<input
|
||||
type="number"
|
||||
value={selectedElement.width}
|
||||
onChange={(e) => updateElement(selectedElement.id, { width: parseInt(e.target.value) })}
|
||||
className="w-full text-xs border border-line rounded px-2 py-1 mt-1 text-ink"
|
||||
min="20"
|
||||
max="300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium">Height</label>
|
||||
<input
|
||||
type="number"
|
||||
value={selectedElement.height}
|
||||
onChange={(e) => updateElement(selectedElement.id, { height: parseInt(e.target.value) })}
|
||||
className="w-full text-xs border border-line rounded px-2 py-1 mt-1 text-ink"
|
||||
min="20"
|
||||
max="300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted">Drag corners on canvas to resize</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pendingId === selectedElement.id && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={confirmElement}
|
||||
className="flex-1 px-3 py-2 bg-splash-blue text-paper rounded text-xs font-medium hover:bg-splash-blue/90"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelElement}
|
||||
className="flex-1 px-3 py-2 bg-splash-pink text-paper rounded text-xs font-medium hover:bg-splash-pink/90"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingId !== selectedElement.id && (
|
||||
<button
|
||||
onClick={() => deleteElement(selectedElement.id)}
|
||||
className="w-full px-3 py-2 bg-splash-pink text-paper rounded text-xs font-medium hover:bg-splash-pink/90"
|
||||
>
|
||||
Delete Element
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-muted">Click on text or image to edit</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add to Cart */}
|
||||
<div className="flex gap-4 items-center pt-6 border-t border-line mt-6">
|
||||
<Price cents={getEffectivePrice(product, [], new Date(), 'personalised').price} />
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="px-6 py-3 bg-clay text-paper rounded font-medium hover:bg-clay-dark"
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { DesignElement, ProductDTO } from '@/lib/types';
|
||||
|
||||
const DISPLAY = 560; // Canvas display size
|
||||
const FONTS = ['Arial', 'Georgia', 'Courier New', 'Times New Roman', 'Verdana', 'Comic Sans MS'];
|
||||
|
||||
interface FabricDesignCanvasV2Props {
|
||||
product: ProductDTO;
|
||||
customPhoto?: string;
|
||||
}
|
||||
|
||||
// Lazy load Fabric.js
|
||||
let fabricLib: any = null;
|
||||
|
||||
const loadFabric = async () => {
|
||||
if (fabricLib) return fabricLib;
|
||||
|
||||
try {
|
||||
console.log('Loading fabric.js...');
|
||||
// Import fabric.js - it exports Canvas, Text, Image, Rect as named exports
|
||||
const mod = await import('fabric');
|
||||
console.log('Fabric module loaded, checking exports...');
|
||||
console.log('Available exports:', Object.keys(mod).slice(0, 20));
|
||||
|
||||
if (!mod.Canvas) {
|
||||
throw new Error('Canvas not found in fabric module exports');
|
||||
}
|
||||
|
||||
fabricLib = {
|
||||
Canvas: mod.Canvas,
|
||||
Text: mod.Text,
|
||||
Image: mod.Image,
|
||||
Rect: mod.Rect,
|
||||
};
|
||||
console.log('Fabric.js setup completed');
|
||||
return fabricLib;
|
||||
} catch (err) {
|
||||
console.error('Failed to import fabric.js:', err);
|
||||
// Return empty object instead of throwing to prevent infinite errors
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export default function FabricDesignCanvasV2({ product, customPhoto }: FabricDesignCanvasV2Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const fabricCanvasRef = useRef<any>(null);
|
||||
const isMountedRef = useRef(false);
|
||||
const [design, setDesign] = useState<{ front: DesignElement[]; back: DesignElement[] }>({
|
||||
front: [],
|
||||
back: [],
|
||||
});
|
||||
const [side, setSide] = useState<'front' | 'back'>('front');
|
||||
const [selectedColor, setSelectedColor] = useState<string>(product.colors[0]);
|
||||
const [history, setHistory] = useState<Array<{ front: DesignElement[]; back: DesignElement[] }>>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
// Track client mounting to avoid hydration issues
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
}, []);
|
||||
|
||||
// Initialize Fabric.js canvas only on client
|
||||
useEffect(() => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
let mounted = true;
|
||||
const init = async () => {
|
||||
if (!canvasRef.current || fabricCanvasRef.current) return;
|
||||
|
||||
try {
|
||||
const fabLib = await loadFabric();
|
||||
if (!mounted) return;
|
||||
|
||||
const Canvas = fabLib.Canvas;
|
||||
if (!Canvas) {
|
||||
throw new Error(`Canvas is undefined. fabLib keys: ${Object.keys(fabLib).join(', ')}`);
|
||||
}
|
||||
|
||||
// Create canvas instance
|
||||
const fabricCanvas = new Canvas(canvasRef.current, {
|
||||
width: DISPLAY,
|
||||
height: DISPLAY,
|
||||
backgroundColor: 'transparent',
|
||||
});
|
||||
|
||||
fabricCanvasRef.current = fabricCanvas;
|
||||
|
||||
// Handle object selection
|
||||
fabricCanvas.on('object:added', () => saveHistory());
|
||||
fabricCanvas.on('object:modified', () => saveHistory());
|
||||
fabricCanvas.on('object:removed', () => saveHistory());
|
||||
|
||||
if (mounted) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize Fabric.js canvas:', err);
|
||||
if (mounted) {
|
||||
setIsLoaded(true); // Still mark as loaded to prevent infinite retry
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (fabricCanvasRef.current) {
|
||||
try {
|
||||
fabricCanvasRef.current.dispose();
|
||||
fabricCanvasRef.current = null;
|
||||
} catch (e) {
|
||||
// Ignore disposal errors
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Render design elements on canvas
|
||||
useEffect(() => {
|
||||
if (!fabricCanvasRef.current || !isLoaded) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
canvas.clear();
|
||||
|
||||
const render = async () => {
|
||||
try {
|
||||
const { Text, Image, Rect } = await loadFabric();
|
||||
|
||||
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
|
||||
|
||||
// Print area is stored as 0-1 decimals; convert to pixels on 560x560 canvas
|
||||
const minX = Math.round(printArea.xPct * DISPLAY);
|
||||
const minY = Math.round(printArea.yPct * DISPLAY);
|
||||
const width = Math.round(printArea.wPct * DISPLAY);
|
||||
const height = Math.round(printArea.hPct * DISPLAY);
|
||||
|
||||
const rect = new Rect({
|
||||
left: minX,
|
||||
top: minY,
|
||||
width,
|
||||
height,
|
||||
fill: 'transparent',
|
||||
stroke: '#E5227E',
|
||||
strokeDashArray: [5, 5],
|
||||
strokeWidth: 2,
|
||||
selectable: false,
|
||||
hasControls: false,
|
||||
});
|
||||
canvas.add(rect);
|
||||
|
||||
// Render design elements
|
||||
const elements = side === 'front' ? design.front : design.back;
|
||||
elements.forEach((el) => {
|
||||
if (el.type === 'text') {
|
||||
const text = new Text(el.text, {
|
||||
left: el.x,
|
||||
top: el.y,
|
||||
fontSize: el.fontSize,
|
||||
fontFamily: el.fontFamily,
|
||||
fill: el.fill,
|
||||
selectable: true,
|
||||
hasControls: true,
|
||||
});
|
||||
canvas.add(text);
|
||||
}
|
||||
// TODO: Add image support after fixing text rendering
|
||||
});
|
||||
|
||||
canvas.renderAll();
|
||||
} catch (err) {
|
||||
console.error('Failed to render canvas:', err);
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
}, [design, side, isLoaded]);
|
||||
|
||||
const renderProductImage = async () => {
|
||||
console.log('renderProductImage called');
|
||||
if (!fabricCanvasRef.current) {
|
||||
console.log('Canvas ref not ready');
|
||||
return;
|
||||
}
|
||||
const canvas = fabricCanvasRef.current;
|
||||
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
|
||||
|
||||
// Get Image and Rect classes
|
||||
const { Image, Rect } = await loadFabric();
|
||||
|
||||
// Determine which image to use
|
||||
let imageUrl: string | null = null;
|
||||
|
||||
console.log('Rendering product image for side:', side);
|
||||
console.log('Product data:', { colorPhotos: product.colorPhotos, imageUrl: product.imageUrl, colorPhotosBack: product.colorPhotosBack, imageUrlBack: product.imageUrlBack, selectedColor });
|
||||
|
||||
if (customPhoto && side === 'front') {
|
||||
imageUrl = customPhoto;
|
||||
console.log('Using customPhoto:', imageUrl);
|
||||
} else if (side === 'front' && product.colorPhotos?.[selectedColor]) {
|
||||
imageUrl = product.colorPhotos[selectedColor];
|
||||
console.log('Using colorPhoto for color:', selectedColor, imageUrl);
|
||||
} else if (side === 'front' && product.imageUrl) {
|
||||
imageUrl = product.imageUrl;
|
||||
console.log('Using imageUrl:', imageUrl);
|
||||
} else if (side === 'back' && product.colorPhotosBack?.[selectedColor]) {
|
||||
imageUrl = product.colorPhotosBack[selectedColor];
|
||||
console.log('Using colorPhotoBack for color:', selectedColor, imageUrl);
|
||||
} else if (side === 'back' && product.imageUrlBack) {
|
||||
imageUrl = product.imageUrlBack;
|
||||
console.log('Using imageUrlBack:', imageUrl);
|
||||
} else if (side === 'back' && product.colorPhotos?.[selectedColor]) {
|
||||
imageUrl = product.colorPhotos[selectedColor];
|
||||
console.log('Fallback: Using colorPhoto for back:', selectedColor, imageUrl);
|
||||
}
|
||||
|
||||
// Draw print area guide only
|
||||
const minX = Math.round(printArea.xPct * DISPLAY);
|
||||
const minY = Math.round(printArea.yPct * DISPLAY);
|
||||
const width = Math.round(printArea.wPct * DISPLAY);
|
||||
const height = Math.round(printArea.hPct * DISPLAY);
|
||||
|
||||
const rect = new Rect({
|
||||
left: minX,
|
||||
top: minY,
|
||||
width,
|
||||
height,
|
||||
fill: 'transparent',
|
||||
stroke: '#E5227E',
|
||||
strokeDashArray: [5, 5],
|
||||
strokeWidth: 2,
|
||||
selectable: false,
|
||||
hasControls: false,
|
||||
});
|
||||
canvas.add(rect);
|
||||
};
|
||||
|
||||
const saveHistory = () => {
|
||||
setHistory((prev) => [...prev.slice(0, historyIndex + 1), { ...design }]);
|
||||
setHistoryIndex((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const addText = () => {
|
||||
const newElement: DesignElement = {
|
||||
id: uuid(),
|
||||
type: 'text',
|
||||
text: 'Click to edit',
|
||||
x: DISPLAY / 2 - 50,
|
||||
y: DISPLAY / 2 - 12,
|
||||
fontSize: 24,
|
||||
fontFamily: 'Arial',
|
||||
fill: '#000000',
|
||||
rotation: 0,
|
||||
};
|
||||
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
[side]: [...prev[side], newElement],
|
||||
}));
|
||||
};
|
||||
|
||||
const addImage = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
const newElement: DesignElement = {
|
||||
id: uuid(),
|
||||
type: 'image',
|
||||
src: evt.target?.result as string,
|
||||
x: DISPLAY / 2 - 50,
|
||||
y: DISPLAY / 2 - 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
rotation: 0,
|
||||
};
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
[side]: [...prev[side], newElement],
|
||||
}));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-8">
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
{/* Canvas Area */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="mb-4 flex gap-2 border-b border-line pb-4">
|
||||
<button
|
||||
onClick={() => setSide('front')}
|
||||
className={`px-4 py-2 ${side === 'front' ? 'border-b-2 border-clay' : ''}`}
|
||||
>
|
||||
Front
|
||||
</button>
|
||||
{product.printAreaBack && (
|
||||
<button
|
||||
onClick={() => setSide('back')}
|
||||
className={`px-4 py-2 ${side === 'back' ? 'border-b-2 border-clay' : ''}`}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color Swatches */}
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-sm font-medium">Garment Color:</p>
|
||||
<div className="flex gap-2">
|
||||
{product.colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setSelectedColor(color)}
|
||||
className={`h-8 w-8 rounded border-2 transition-all ${
|
||||
selectedColor === color ? 'border-clay ring-2 ring-clay' : 'border-line'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas with Product Image */}
|
||||
<div className="border border-line bg-white p-4 relative inline-block w-full">
|
||||
{/* Product image background */}
|
||||
<img
|
||||
src={product.colorPhotos?.[selectedColor] || product.imageUrl || ''}
|
||||
alt="Product"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
{/* Fabric canvas overlay */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={addText}
|
||||
className="rounded bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay/90"
|
||||
>
|
||||
Add Text
|
||||
</button>
|
||||
<button
|
||||
onClick={addImage}
|
||||
className="rounded bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay/90"
|
||||
>
|
||||
Add Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div>
|
||||
<div className="rounded border border-line bg-surface p-4">
|
||||
<h3 className="mb-4 font-medium">Properties</h3>
|
||||
<p className="text-sm text-muted">Click on text or image to edit</p>
|
||||
<div className="mt-4">
|
||||
<p className="mb-4 font-display text-xl">
|
||||
£{(product.effectivePrice / 100).toFixed(2)}
|
||||
</p>
|
||||
<button className="w-full rounded bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay/90">
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import NewsletterForm from './NewsletterForm';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-line">
|
||||
<footer suppressHydrationWarning className="border-t border-line">
|
||||
<div className="h-1 w-full brand-gradient" />
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
<div className="grid gap-8 md:grid-cols-4">
|
||||
|
||||
@@ -30,8 +30,8 @@ export default async function Header() {
|
||||
links: [
|
||||
{ href: `/made-to-order?category=${c.slug}`, label: 'All' },
|
||||
...c.children
|
||||
.filter((ch) => ch.showOnPersonalised)
|
||||
.map((child) => ({ href: `/made-to-order?category=${child.slug}`, label: child.name })),
|
||||
.filter((ch: any) => ch.showOnPersonalised)
|
||||
.map((child: any) => ({ href: `/made-to-order?category=${child.slug}`, label: child.name })),
|
||||
],
|
||||
})),
|
||||
];
|
||||
@@ -43,8 +43,8 @@ export default async function Header() {
|
||||
links: [
|
||||
{ href: `/products?category=${c.slug}`, label: 'All' },
|
||||
...c.children
|
||||
.filter((ch) => ch.showOnProducts)
|
||||
.map((child) => ({ href: `/products?category=${child.slug}`, label: child.name })),
|
||||
.filter((ch: any) => ch.showOnProducts)
|
||||
.map((child: any) => ({ href: `/products?category=${child.slug}`, label: child.name })),
|
||||
],
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -23,8 +23,9 @@ export default function NewsletterForm() {
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2" suppressHydrationWarning>
|
||||
<input
|
||||
suppressHydrationWarning
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
|
||||
@@ -11,10 +11,12 @@ export default function PhotoPrintAreaField({
|
||||
variant = 'front',
|
||||
existingImageUrl,
|
||||
existingPrintArea,
|
||||
colorPhotosPreview,
|
||||
}: {
|
||||
variant?: 'front' | 'back';
|
||||
existingImageUrl?: string;
|
||||
existingPrintArea?: Box;
|
||||
colorPhotosPreview?: Record<string, string>;
|
||||
}) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [box, setBox] = useState<Box>(existingPrintArea || DEFAULT_BOX);
|
||||
@@ -60,7 +62,9 @@ export default function PhotoPrintAreaField({
|
||||
setShowPrintAreaEditor(false);
|
||||
};
|
||||
|
||||
const currentImageUrl = previewUrl || (isRemoved ? null : existingImageUrl);
|
||||
// Use preview from new upload, or existing base image, or first colorPhoto if available
|
||||
const colorPhotoPreview = colorPhotosPreview ? Object.values(colorPhotosPreview)[0] : null;
|
||||
const currentImageUrl = previewUrl || (isRemoved ? null : existingImageUrl) || colorPhotoPreview;
|
||||
|
||||
const onBoxMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -130,12 +134,14 @@ export default function PhotoPrintAreaField({
|
||||
Uploading a real photo replaces the illustration everywhere this product is shown. One
|
||||
tradeoff: the color swatches below can no longer recolor a photo the way they recolor the
|
||||
illustration — they'll still be saved with each order, but the picture itself stays
|
||||
fixed. Leave this blank to keep using the illustration (which does recolor live).
|
||||
fixed. Leave this blank to keep using the illustration (which does recolor live). Use the
|
||||
print area editor below to set where designs can be placed.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
If set, customers get a Front/Back toggle and can add a separate design to the back —
|
||||
drag a print-area box below just like the front.
|
||||
drag a print-area box below just like the front. Use the print area editor to set where
|
||||
designs can be placed on the back.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -60,11 +60,13 @@ export default function StandardProductView({ product }: { product: ProductDTO }
|
||||
size,
|
||||
quantity,
|
||||
unitPrice: product.effectivePrice,
|
||||
designJson: { front: [], back: [] },
|
||||
designJson: { front: [], back: [] } as any,
|
||||
designPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? '',
|
||||
designPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null,
|
||||
placementPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? null,
|
||||
placementPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null,
|
||||
designWidthCm: null,
|
||||
designHeightCm: null,
|
||||
});
|
||||
setShowAddToCartModal(true);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function ThemeInitializer() {
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDark = stored ? stored === 'dark' : prefersDark;
|
||||
if (isDark) document.documentElement.classList.add('dark');
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import Script from 'next/script';
|
||||
|
||||
declare global {
|
||||
@@ -33,9 +33,13 @@ export default function Turnstile() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const widgetIdRef = useRef<string | null>(null);
|
||||
const [scriptReady, setScriptReady] = useState(false);
|
||||
const readyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && window.turnstile) setScriptReady(true);
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window !== 'undefined' && window.turnstile && !readyRef.current) {
|
||||
readyRef.current = true;
|
||||
setScriptReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,7 +72,12 @@ export default function Turnstile() {
|
||||
<Script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
||||
strategy="afterInteractive"
|
||||
onReady={() => setScriptReady(true)}
|
||||
onLoad={() => {
|
||||
if (!readyRef.current) {
|
||||
readyRef.current = true;
|
||||
setScriptReady(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div ref={containerRef} />
|
||||
</>
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function createAdminSession() {
|
||||
.setExpirationTime(`${TOKEN_DURATION_SECONDS}s`)
|
||||
.sign(adminSecretKey());
|
||||
|
||||
cookies().set(ADMIN_SESSION_COOKIE, token, {
|
||||
(await cookies()).set(ADMIN_SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
@@ -31,12 +31,12 @@ export async function createAdminSession() {
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAdminSession() {
|
||||
cookies().delete(ADMIN_SESSION_COOKIE);
|
||||
export async function clearAdminSession() {
|
||||
(await cookies()).delete(ADMIN_SESSION_COOKIE);
|
||||
}
|
||||
|
||||
export async function isAdminAuthenticated() {
|
||||
const token = cookies().get(ADMIN_SESSION_COOKIE)?.value;
|
||||
const token = (await cookies()).get(ADMIN_SESSION_COOKIE)?.value;
|
||||
if (!token) return false;
|
||||
|
||||
try {
|
||||
|
||||
+4
-4
@@ -27,7 +27,7 @@ export async function createSession(customerId: string) {
|
||||
.setExpirationTime(`${SESSION_DURATION_SECONDS}s`)
|
||||
.sign(sessionSecretKey());
|
||||
|
||||
cookies().set(SESSION_COOKIE, token, {
|
||||
(await cookies()).set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
@@ -36,12 +36,12 @@ export async function createSession(customerId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
cookies().delete(SESSION_COOKIE);
|
||||
export async function clearSession() {
|
||||
(await cookies()).delete(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
export async function getCurrentCustomer() {
|
||||
const token = cookies().get(SESSION_COOKIE)?.value;
|
||||
const token = (await cookies()).get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
|
||||
+4
-4
@@ -26,8 +26,8 @@ function setCached<T>(key: string, data: T): void {
|
||||
cache.set(key, { data, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
export async function getCategoriesWithChildren() {
|
||||
const cached = getCached('categories_with_children');
|
||||
export async function getCategoriesWithChildren(): Promise<any[]> {
|
||||
const cached = getCached<any[]>('categories_with_children');
|
||||
if (cached) return cached;
|
||||
|
||||
const categories = await prisma.category.findMany({
|
||||
@@ -56,8 +56,8 @@ export async function getCategoriesWithChildren() {
|
||||
return categories;
|
||||
}
|
||||
|
||||
export async function getCollections() {
|
||||
const cached = getCached('collections');
|
||||
export async function getCollections(): Promise<any[]> {
|
||||
const cached = getCached<any[]>('collections');
|
||||
if (cached) return cached;
|
||||
|
||||
const collections = await prisma.collection.findMany({
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function logFinancialChange(
|
||||
reason?: string
|
||||
) {
|
||||
try {
|
||||
const ip = getClientIp(headers());
|
||||
const ip = getClientIp(await headers());
|
||||
|
||||
await prisma.financialAuditLog.create({
|
||||
data: {
|
||||
|
||||
+24
-6
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -11,13 +15,27 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user