Initial commit: Craft2Prints with Stages 1-3
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { logoutCustomer } from '@/app/account/actions';
|
||||
|
||||
type AccountMenuProps = {
|
||||
customer: { name: string | null; email: string } | null;
|
||||
};
|
||||
|
||||
export default function AccountMenu({ customer }: AccountMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
return () => document.removeEventListener('mousedown', onClickOutside);
|
||||
}, []);
|
||||
|
||||
if (!customer) {
|
||||
return (
|
||||
<Link href="/account/login" className="tag-label hover:text-ink">
|
||||
Login
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="tag-label flex items-center gap-1 hover:text-ink"
|
||||
>
|
||||
{customer.name ?? 'Account'}
|
||||
<span className={`inline-block transition-transform ${open ? 'rotate-180' : ''}`}>▾</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 w-48 border border-line bg-paper shadow-sm">
|
||||
<Link href="/account" onClick={() => setOpen(false)} className="block px-4 py-3 text-sm hover:bg-surface">
|
||||
My account
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowLogoutConfirm(true)}
|
||||
className="block w-full px-4 py-3 text-left text-sm hover:bg-surface"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLogoutConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="w-96 border border-line bg-paper p-6 shadow-lg">
|
||||
<h2 className="font-display text-lg">Log out?</h2>
|
||||
<p className="mt-3 text-sm text-muted">
|
||||
Your cart will be saved. You'll need to log in again to complete your order.
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowLogoutConfirm(false)}
|
||||
className="flex-1 border border-line px-4 py-2 text-sm hover:bg-surface"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<form action={logoutCustomer} className="flex-1">
|
||||
<button type="submit" className="w-full bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Log out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
const TABS = [
|
||||
{ href: '/admin/accounts', label: 'Ledger' },
|
||||
{ href: '/admin/accounts/stock', label: 'Stock' },
|
||||
{ href: '/admin/accounts/purchases', label: 'Purchases' },
|
||||
] as const;
|
||||
|
||||
export default function AccountsNav({ active }: { active: (typeof TABS)[number]['href'] }) {
|
||||
return (
|
||||
<div className="mt-4 inline-flex border border-line">
|
||||
{TABS.map((tab) => (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`px-4 py-1.5 text-sm ${active === tab.href ? 'bg-ink text-paper' : 'hover:text-clay'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { logoutAdmin } from '@/app/admin/login/actions';
|
||||
|
||||
const ADMIN_LINKS = [
|
||||
{ href: '/admin/orders', label: 'Orders' },
|
||||
{ href: '/admin/accounts', label: 'Accounts' },
|
||||
{ href: '/admin/products/new', label: 'Add product' },
|
||||
{ href: '/admin/products', label: 'All products' },
|
||||
{ href: '/admin/categories/new', label: 'Add category' },
|
||||
{ href: '/admin/categories', label: 'All categories' },
|
||||
{ href: '/admin/proofs/new', label: 'Upload new design' },
|
||||
{ href: '/admin/proofs', label: 'All proofs' },
|
||||
{ href: '/admin/inbox', label: 'Inbox' },
|
||||
{ href: '/admin/custom-requests', label: 'Photo requests' },
|
||||
{ href: '/admin/promotions', label: 'Promotions' },
|
||||
{ href: '/admin/gallery', label: 'Gallery' },
|
||||
{ href: '/admin/gallery/categories', label: 'Gallery categories' },
|
||||
{ href: '/admin/collections/new', label: 'Add collection' },
|
||||
{ href: '/admin/collections', label: 'All collections' },
|
||||
{ href: '/admin/customers', label: 'Email customers' },
|
||||
{ href: '/admin/maintenance', label: 'Maintenance' },
|
||||
];
|
||||
|
||||
export default function AdminMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
return () => document.removeEventListener('mousedown', onClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative hidden md:block">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="tag-label flex items-center gap-1 hover:text-ink"
|
||||
>
|
||||
Admin
|
||||
<span className={`inline-block transition-transform ${open ? 'rotate-180' : ''}`}>▾</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 w-48 border border-line bg-paper shadow-sm">
|
||||
{ADMIN_LINKS.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block px-4 py-3 text-sm hover:bg-surface"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<form action={logoutAdmin}>
|
||||
<button type="submit" className="block w-full px-4 py-3 text-left text-sm hover:bg-surface">
|
||||
Log out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AdminNav() {
|
||||
return (
|
||||
<div className="mb-8 flex flex-wrap gap-6 border-b border-line pb-4">
|
||||
<Link href="/admin/orders" className="tag-label hover:text-ink">
|
||||
Orders
|
||||
</Link>
|
||||
<Link href="/admin/products/new" className="tag-label hover:text-ink">
|
||||
Add product
|
||||
</Link>
|
||||
<Link href="/admin/products" className="tag-label hover:text-ink">
|
||||
All products
|
||||
</Link>
|
||||
<Link href="/admin/categories/new" className="tag-label hover:text-ink">
|
||||
Add category
|
||||
</Link>
|
||||
<Link href="/admin/categories" className="tag-label hover:text-ink">
|
||||
All categories
|
||||
</Link>
|
||||
<Link href="/admin/proofs/new" className="tag-label hover:text-ink">
|
||||
Upload new design
|
||||
</Link>
|
||||
<Link href="/admin/proofs" className="tag-label hover:text-ink">
|
||||
All proofs
|
||||
</Link>
|
||||
<Link href="/admin/inbox" className="tag-label hover:text-ink">
|
||||
Inbox
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { useCart } from '@/lib/cartStore';
|
||||
import type { ProofDTO } from '@/lib/types';
|
||||
|
||||
export default function ApproveSection({ proof }: { proof: ProofDTO }) {
|
||||
const [status, setStatus] = useState<ProofDTO['status']>(proof.status);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const addItem = useCart((s) => s.addItem);
|
||||
const router = useRouter();
|
||||
|
||||
const approve = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await fetch(`/api/proofs/${proof.id}/approve`, { method: 'POST' });
|
||||
addItem({
|
||||
cartItemId: uuid(),
|
||||
productId: proof.productId,
|
||||
slug: proof.productSlug,
|
||||
name: proof.productName,
|
||||
mockup: '',
|
||||
color: proof.color,
|
||||
size: null,
|
||||
quantity: proof.quantity,
|
||||
unitPrice: proof.unitPrice,
|
||||
designJson: { front: [], back: [] },
|
||||
designPreviewUrl: proof.imageDataUrl,
|
||||
designPreviewUrlBack: null,
|
||||
placementPreviewUrl: proof.imageDataUrl,
|
||||
placementPreviewUrlBack: null,
|
||||
});
|
||||
setStatus('APPROVED');
|
||||
router.push('/cart');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'APPROVED') {
|
||||
return (
|
||||
<div className="border border-splash-blue bg-splash-blue/5 p-4 text-sm text-ink">
|
||||
Approved — this design is in your cart.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={approve}
|
||||
disabled={loading}
|
||||
className="w-full bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark disabled:opacity-60"
|
||||
>
|
||||
{loading ? 'Adding to cart…' : 'Approve & add to bag'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCart } from '@/lib/cartStore';
|
||||
|
||||
export default function CartButton() {
|
||||
// Avoid hydration mismatch: item count only read after mount.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const items = useCart((s) => s.items);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const count = mounted ? items.reduce((n, i) => n + i.quantity, 0) : 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/cart"
|
||||
className="group flex items-center gap-2 border border-ink px-4 py-2 text-sm transition-colors hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
<span className="font-mono">Bag</span>
|
||||
<span className="font-mono tabular-nums">({count})</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { MessageDTO } from '@/lib/types';
|
||||
|
||||
const POLL_MS = 4000;
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function Chat({
|
||||
proofId,
|
||||
role,
|
||||
otherPartyLabel,
|
||||
}: {
|
||||
proofId: string;
|
||||
role: 'ADMIN' | 'CUSTOMER';
|
||||
otherPartyLabel: string;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<MessageDTO[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/proofs/${proofId}/messages`);
|
||||
if (!res.ok || cancelled) return;
|
||||
const data: MessageDTO[] = await res.json();
|
||||
if (!cancelled) setMessages(data);
|
||||
} catch {
|
||||
// silently retry on next poll
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
const interval = setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [proofId]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
||||
}, [messages.length]);
|
||||
|
||||
const send = async () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || sending) return;
|
||||
setSending(true);
|
||||
setInput('');
|
||||
try {
|
||||
const res = await fetch(`/api/proofs/${proofId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sender: role, body: trimmed }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const created: MessageDTO = await res.json();
|
||||
setMessages((prev) => [...prev, created]);
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-line bg-surface">
|
||||
<div className="border-b border-line px-4 py-3">
|
||||
<p className="tag-label">Chat with {otherPartyLabel}</p>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="max-h-72 space-y-3 overflow-y-auto p-4">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-sm text-muted">No messages yet — say hello.</p>
|
||||
) : (
|
||||
messages.map((m) => {
|
||||
const mine = m.sender === role;
|
||||
return (
|
||||
<div key={m.id} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[75%] ${mine ? 'bg-clay text-paper' : 'bg-paper border border-line text-ink'} px-3 py-2`}>
|
||||
<p className="text-sm">{m.body}</p>
|
||||
<p className={`mt-1 text-[10px] ${mine ? 'text-paper/60' : 'text-muted'}`}>
|
||||
{formatTime(m.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-t border-line p-3">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
placeholder="Type a message…"
|
||||
className="flex-1 border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={sending || !input.trim()}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// A small decorative 5-petal blossom — plain flat SVG, no assets, matches the
|
||||
// hand-drawn look of ProductMockup.tsx. Purely decorative: aria-hidden, no
|
||||
// interactivity needed.
|
||||
export default function CherryBlossom({
|
||||
className = '',
|
||||
size = 32,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg viewBox="0 0 40 40" width={size} height={size} className={className} style={style} aria-hidden>
|
||||
<g fill="#E5227E">
|
||||
{[0, 72, 144, 216, 288].map((angle) => (
|
||||
<ellipse key={angle} cx="20" cy="11" rx="5.5" ry="8" transform={`rotate(${angle} 20 20)`} />
|
||||
))}
|
||||
</g>
|
||||
<circle cx="20" cy="20" r="2.5" fill="#F5871F" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useCart } from '@/lib/cartStore';
|
||||
|
||||
export default function ClearCartOnMount() {
|
||||
const clear = useCart((s) => s.clear);
|
||||
useEffect(() => {
|
||||
clear();
|
||||
}, [clear]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
function frontFieldName(hex: string) {
|
||||
return `colorPhoto__${hex.replace('#', '')}`;
|
||||
}
|
||||
function backFieldName(hex: string) {
|
||||
return `colorPhotoBack__${hex.replace('#', '')}`;
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new window.Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
// Samples the photo on an offscreen canvas and averages the non-background
|
||||
// pixels — product photos are typically shot on a plain white/light backdrop,
|
||||
// so pixels that are both very light and low-saturation (the backdrop) are
|
||||
// excluded, leaving mostly garment pixels to average into one hex color.
|
||||
async function detectDominantColor(file: File): Promise<string> {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = await loadImage(objectUrl);
|
||||
const size = 120;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return '#808080';
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
const { data } = ctx.getImageData(0, 0, size, size);
|
||||
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const pr = data[i];
|
||||
const pg = data[i + 1];
|
||||
const pb = data[i + 2];
|
||||
const pa = data[i + 3];
|
||||
if (pa < 200) continue;
|
||||
const max = Math.max(pr, pg, pb);
|
||||
const min = Math.min(pr, pg, pb);
|
||||
const lightness = (max + min) / 2 / 255;
|
||||
const sat = max === min ? 0 : (max - min) / (255 - Math.abs(max + min - 255));
|
||||
if (lightness > 0.92 && sat < 0.12) continue; // near-white/gray backdrop
|
||||
r += pr;
|
||||
g += pg;
|
||||
b += pb;
|
||||
count += 1;
|
||||
}
|
||||
if (count === 0) {
|
||||
r = data[0];
|
||||
g = data[1];
|
||||
b = data[2];
|
||||
count = 1;
|
||||
}
|
||||
r = Math.round(r / count);
|
||||
g = Math.round(g / count);
|
||||
b = Math.round(b / count);
|
||||
return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`;
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function hexDistance(a: string, b: string) {
|
||||
const [ar, ag, ab] = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
|
||||
const [br, bg, bb] = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
|
||||
return Math.sqrt((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2);
|
||||
}
|
||||
|
||||
// Snaps a freshly-detected color onto an existing one if they're close
|
||||
// (different lighting/JPEG noise shouldn't produce two near-identical
|
||||
// swatches for what's really the same shirt color).
|
||||
function findNearbyColor(hex: string, existing: string[], threshold = 30) {
|
||||
return existing.find((c) => hexDistance(c, hex) < threshold) ?? null;
|
||||
}
|
||||
|
||||
type BulkResult = { filename: string; hex: string; variant: 'front' | 'back' };
|
||||
|
||||
export default function ColorPicker({
|
||||
name,
|
||||
defaultColors = [],
|
||||
defaultColorPhotos = {},
|
||||
defaultColorPhotosBack = {},
|
||||
}: {
|
||||
name: string;
|
||||
defaultColors?: string[];
|
||||
defaultColorPhotos?: Record<string, string>;
|
||||
defaultColorPhotosBack?: Record<string, string>;
|
||||
}) {
|
||||
const [colors, setColors] = useState<string[]>(defaultColors.length ? defaultColors : ['#17181C']);
|
||||
const [picked, setPicked] = useState('#1EA7E0');
|
||||
// Local previews for newly-picked files, so admins see what they just attached.
|
||||
const [previews, setPreviews] = useState<Record<string, string>>({});
|
||||
const [previewsBack, setPreviewsBack] = useState<Record<string, string>>({});
|
||||
|
||||
const bulkInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fileInputRefs = useRef<Map<string, HTMLInputElement>>(new Map());
|
||||
const pendingFilesRef = useRef<Map<string, File>>(new Map());
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [bulkResults, setBulkResults] = useState<BulkResult[]>([]);
|
||||
|
||||
const addColor = () => {
|
||||
if (colors.includes(picked)) return;
|
||||
setColors((prev) => [...prev, picked]);
|
||||
};
|
||||
|
||||
const removeColor = (c: string) => {
|
||||
setColors((prev) => prev.filter((x) => x !== c));
|
||||
};
|
||||
|
||||
const handlePhotoChange = (hex: string, file: File | null, back: boolean) => {
|
||||
const setter = back ? setPreviewsBack : setPreviews;
|
||||
if (!file) {
|
||||
setter((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[hex];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setter((prev) => ({ ...prev, [hex]: URL.createObjectURL(file) }));
|
||||
};
|
||||
|
||||
// Once a pending bulk-detected file's color has a rendered <input> (i.e. its
|
||||
// color row just mounted), push the File into that native input via
|
||||
// DataTransfer and fire the same change handler a manual pick would — so
|
||||
// the preview and the real file both update, and the form submits it
|
||||
// exactly like any other per-color upload.
|
||||
useEffect(() => {
|
||||
if (pendingFilesRef.current.size === 0) return;
|
||||
for (const [key, file] of pendingFilesRef.current) {
|
||||
const input = fileInputRefs.current.get(key);
|
||||
if (!input) continue;
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
input.files = dt.files;
|
||||
const [hex, variant] = key.split('__');
|
||||
handlePhotoChange(hex, file, variant === 'back');
|
||||
pendingFilesRef.current.delete(key);
|
||||
}
|
||||
}, [colors]);
|
||||
|
||||
const handleBulkFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setBulkBusy(true);
|
||||
setBulkResults([]);
|
||||
const results: BulkResult[] = [];
|
||||
const nextColors = [...colors];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
const variant: 'front' | 'back' = /back/i.test(file.name) ? 'back' : 'front';
|
||||
const detected = await detectDominantColor(file);
|
||||
const hex = findNearbyColor(detected, nextColors) ?? detected;
|
||||
if (!nextColors.includes(hex)) nextColors.push(hex);
|
||||
|
||||
const key = `${hex}__${variant}`;
|
||||
pendingFilesRef.current.set(key, file);
|
||||
results.push({ filename: file.name, hex, variant });
|
||||
}
|
||||
|
||||
setColors(nextColors);
|
||||
setBulkResults(results);
|
||||
setBulkBusy(false);
|
||||
if (bulkInputRef.current) bulkInputRef.current.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input type="hidden" name={name} value={colors.join(',')} />
|
||||
|
||||
<div className="mb-4 border border-line bg-surface p-3">
|
||||
<p className="tag-label mb-1">Bulk upload from a folder</p>
|
||||
<p className="mb-2 text-xs text-muted">
|
||||
Point at a folder of T-shirt photos and each one is scanned to detect the shirt's
|
||||
color and added automatically. Name a file with "back" in it (e.g.{' '}
|
||||
<code>navy-back.jpg</code>) to attach it as that color's back photo instead of front.
|
||||
</p>
|
||||
<input
|
||||
ref={bulkInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
// @ts-expect-error non-standard attributes for folder selection, supported in Chromium/Firefox
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
onChange={(e) => handleBulkFiles(e.target.files)}
|
||||
className="text-xs"
|
||||
/>
|
||||
{bulkBusy && <p className="mt-2 text-xs text-muted">Scanning images…</p>}
|
||||
{bulkResults.length > 0 && !bulkBusy && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{bulkResults.map((r, i) => (
|
||||
<div key={`${r.filename}-${i}`} className="flex items-center gap-2 text-xs">
|
||||
<div className="h-4 w-4 shrink-0 rounded-full border border-line" style={{ backgroundColor: r.hex }} />
|
||||
<span className="font-mono text-muted">{r.hex}</span>
|
||||
<span className="truncate text-muted">{r.filename}</span>
|
||||
<span className="tag-label shrink-0 text-muted">{r.variant}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{colors.map((c) => (
|
||||
<div key={c} className="group relative">
|
||||
<div
|
||||
className="h-10 w-10 rounded-full border border-line"
|
||||
style={{ backgroundColor: c }}
|
||||
title={c}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeColor(c)}
|
||||
aria-label={`Remove ${c}`}
|
||||
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full border border-line bg-paper text-xs opacity-0 transition-opacity group-hover:opacity-100 hover:border-splash-pink hover:text-splash-pink"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={picked}
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
className="h-9 w-9 cursor-pointer border border-line bg-paper p-0"
|
||||
/>
|
||||
<span className="font-mono text-xs text-muted">{picked}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addColor}
|
||||
className="border border-ink px-3 py-1.5 text-xs hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
+ Add color
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{colors.length === 0 && <p className="mt-2 text-xs text-splash-pink">Add at least one color.</p>}
|
||||
|
||||
{colors.length > 0 && (
|
||||
<div className="mt-5 border-t border-line pt-4">
|
||||
<p className="tag-label mb-1">Photo per color (optional)</p>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
Attach the real front and/or back photo for a color and it'll be shown exactly as-is
|
||||
when a customer picks that color — more accurate than tinting one photo. Leave either
|
||||
blank to fall back to the product's main front/back photo (or the illustration).
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{colors.map((hex) => {
|
||||
const preview = previews[hex] ?? defaultColorPhotos[hex];
|
||||
const previewBack = previewsBack[hex] ?? defaultColorPhotosBack[hex];
|
||||
return (
|
||||
<div key={hex} className="border border-line p-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-6 w-6 shrink-0 rounded-full border border-line" style={{ backgroundColor: hex }} />
|
||||
<span className="font-mono text-xs text-muted">{hex}</span>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="tag-label w-10 shrink-0">Front</span>
|
||||
{preview && (
|
||||
<img src={preview} alt={`${hex} front preview`} className="h-9 w-9 shrink-0 border border-line object-cover" />
|
||||
)}
|
||||
<input
|
||||
ref={(el) => {
|
||||
if (el) fileInputRefs.current.set(`${hex}__front`, el);
|
||||
}}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
name={frontFieldName(hex)}
|
||||
onChange={(e) => handlePhotoChange(hex, e.target.files?.[0] ?? null, false)}
|
||||
className="min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="tag-label w-10 shrink-0">Back</span>
|
||||
{previewBack && (
|
||||
<img src={previewBack} alt={`${hex} back preview`} className="h-9 w-9 shrink-0 border border-line object-cover" />
|
||||
)}
|
||||
<input
|
||||
ref={(el) => {
|
||||
if (el) fileInputRefs.current.set(`${hex}__back`, el);
|
||||
}}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
name={backFieldName(hex)}
|
||||
onChange={(e) => handlePhotoChange(hex, e.target.files?.[0] ?? null, true)}
|
||||
className="min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function CopyLink({ link }: { link: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(link);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={link}
|
||||
onFocus={(e) => e.target.select()}
|
||||
className="w-full border border-line px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={copy}
|
||||
className="whitespace-nowrap border border-ink px-4 py-2 text-sm hover:bg-ink hover:text-paper"
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy link'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCurrency } from '@/lib/CurrencyContext';
|
||||
import { CURRENCIES } from '@/lib/currency';
|
||||
|
||||
export default function CurrencySelector() {
|
||||
const { currency, setCurrency } = useCurrency();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
return () => document.removeEventListener('mousedown', onClickOutside);
|
||||
}, []);
|
||||
|
||||
const active = CURRENCIES.find((c) => c.code === currency) ?? CURRENCIES[0];
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative hidden md:block">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="tag-label flex items-center gap-1 hover:text-ink"
|
||||
>
|
||||
{active.symbol} {active.code}
|
||||
<span className={`inline-block transition-transform ${open ? 'rotate-180' : ''}`}>▾</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 w-44 border border-line bg-paper shadow-sm">
|
||||
{CURRENCIES.map((c) => (
|
||||
<button
|
||||
key={c.code}
|
||||
onClick={() => {
|
||||
setCurrency(c.code);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`block w-full px-4 py-3 text-left text-sm hover:bg-surface ${
|
||||
c.code === currency ? 'text-clay' : ''
|
||||
}`}
|
||||
>
|
||||
{c.symbol} {c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,866 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect, useLayoutEffect, useCallback } from 'react';
|
||||
import { Stage, Layer, Text, Image as KonvaImage, Transformer, Rect } from 'react-konva';
|
||||
import type Konva from 'konva';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import ProductMockup from './ProductMockup';
|
||||
import Price from './Price';
|
||||
import { useCart } from '@/lib/cartStore';
|
||||
import { getEffectivePrice } from '@/lib/pricing';
|
||||
import type { DesignElement, PrintArea, ProductDTO } from '@/lib/types';
|
||||
|
||||
const DISPLAY = 560; // css px size the mockup + stage are rendered at
|
||||
const CAPTURE_SCALE = 2; // matches pixelRatio used for exports, for crisp downloads
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new window.Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
// Draws `img` into the canvas region like CSS `object-fit: cover` would.
|
||||
function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, dw: number, dh: number) {
|
||||
const imgRatio = img.width / img.height;
|
||||
const boxRatio = dw / dh;
|
||||
let sx: number, sy: number, sw: number, sh: number;
|
||||
if (imgRatio > boxRatio) {
|
||||
sh = img.height;
|
||||
sw = sh * boxRatio;
|
||||
sx = (img.width - sw) / 2;
|
||||
sy = 0;
|
||||
} else {
|
||||
sw = img.width;
|
||||
sh = sw / boxRatio;
|
||||
sx = 0;
|
||||
sy = (img.height - sh) / 2;
|
||||
}
|
||||
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, dw, dh);
|
||||
}
|
||||
|
||||
// Builds a reference image showing the design placed on the actual product photo
|
||||
// (garment + artwork composited together) — separate from the clean, print-ready,
|
||||
// transparent-background export. Returns null if there's no real photo to composite
|
||||
// onto (illustration-only products skip this; there's nothing photographic to show).
|
||||
async function composePlacementImage(
|
||||
photoUrl: string,
|
||||
recolor: { tint: boolean; color: string } | null,
|
||||
stage: Konva.Stage,
|
||||
box: { left: number; top: number; w: number; h: number },
|
||||
): Promise<string> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = DISPLAY * CAPTURE_SCALE;
|
||||
canvas.height = DISPLAY * CAPTURE_SCALE;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
|
||||
const photoImg = await loadImage(photoUrl);
|
||||
|
||||
if (recolor?.tint) {
|
||||
ctx.filter = 'grayscale(1) brightness(2.1) contrast(1.15)';
|
||||
drawCover(ctx, photoImg, canvas.width, canvas.height);
|
||||
ctx.filter = 'none';
|
||||
ctx.globalCompositeOperation = 'multiply';
|
||||
ctx.fillStyle = recolor.color;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
} else {
|
||||
drawCover(ctx, photoImg, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
const designCanvas = stage.toCanvas({ pixelRatio: CAPTURE_SCALE });
|
||||
ctx.drawImage(
|
||||
designCanvas,
|
||||
box.left * CAPTURE_SCALE,
|
||||
box.top * CAPTURE_SCALE,
|
||||
box.w * CAPTURE_SCALE,
|
||||
box.h * CAPTURE_SCALE,
|
||||
);
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
const FONTS = [
|
||||
'Inter',
|
||||
'Fraunces',
|
||||
'Caveat',
|
||||
'Playfair Display',
|
||||
'Oswald',
|
||||
'Bebas Neue',
|
||||
'Pacifico',
|
||||
'Permanent Marker',
|
||||
'JetBrains Mono',
|
||||
'Georgia',
|
||||
'Arial',
|
||||
];
|
||||
|
||||
function UploadedImage({
|
||||
el,
|
||||
onSelect,
|
||||
onChange,
|
||||
shapeRef,
|
||||
}: {
|
||||
el: Extract<DesignElement, { type: 'image' }>;
|
||||
onSelect: () => void;
|
||||
onChange: (attrs: Partial<DesignElement>) => void;
|
||||
shapeRef: (node: Konva.Image | null) => void;
|
||||
}) {
|
||||
const [img, setImg] = useState<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const image = new window.Image();
|
||||
image.src = el.src;
|
||||
image.onload = () => setImg(image);
|
||||
}, [el.src]);
|
||||
|
||||
if (!img) return null;
|
||||
|
||||
return (
|
||||
<KonvaImage
|
||||
ref={shapeRef}
|
||||
image={img}
|
||||
x={el.x}
|
||||
y={el.y}
|
||||
width={el.width}
|
||||
height={el.height}
|
||||
rotation={el.rotation}
|
||||
draggable
|
||||
listening={true}
|
||||
onClick={onSelect}
|
||||
onTap={onSelect}
|
||||
onDragEnd={(e) => onChange({ x: e.target.x(), y: e.target.y() })}
|
||||
onTransformEnd={(e) => {
|
||||
const node = e.target;
|
||||
onChange({
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
rotation: node.rotation(),
|
||||
width: Math.max(10, node.width() * node.scaleX()),
|
||||
height: Math.max(10, node.height() * node.scaleY()),
|
||||
});
|
||||
node.scaleX(1);
|
||||
node.scaleY(1);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// One print surface (photo + the draggable design layer on top). Front and back
|
||||
// each get their own instance, both always mounted (just shown/hidden with CSS)
|
||||
// so their Konva stages stay exportable even when you're looking at the other side.
|
||||
function PrintSurface({
|
||||
photo,
|
||||
printArea,
|
||||
elements,
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
updateElement,
|
||||
nodeRefs,
|
||||
stageRef,
|
||||
transformerRef,
|
||||
visible,
|
||||
caption,
|
||||
onNodeReady,
|
||||
scale,
|
||||
}: {
|
||||
photo: React.ReactNode;
|
||||
printArea: PrintArea;
|
||||
elements: DesignElement[];
|
||||
selectedId: string | null;
|
||||
setSelectedId: (id: string | null) => void;
|
||||
updateElement: (id: string, attrs: Partial<DesignElement>) => void;
|
||||
nodeRefs: React.MutableRefObject<Map<string, Konva.Node>>;
|
||||
stageRef: React.RefObject<Konva.Stage>;
|
||||
transformerRef: React.RefObject<Konva.Transformer>;
|
||||
visible: boolean;
|
||||
caption: string;
|
||||
onNodeReady: () => void;
|
||||
scale: number;
|
||||
}) {
|
||||
const stageW = printArea.wPct * DISPLAY;
|
||||
const stageH = printArea.hPct * DISPLAY;
|
||||
const stageLeft = printArea.xPct * DISPLAY;
|
||||
const stageTop = printArea.yPct * DISPLAY;
|
||||
|
||||
return (
|
||||
<div className={visible ? '' : 'hidden'}>
|
||||
{/* Konva's Stage stays a fixed 560x560 internally so element coordinates,
|
||||
print-area math, and export resolution never change — only the visual
|
||||
box shrinks via CSS transform to fit narrow viewports. */}
|
||||
<div style={{ width: DISPLAY * scale, height: DISPLAY * scale }}>
|
||||
<div
|
||||
className="relative border border-line bg-surface"
|
||||
style={{ width: DISPLAY, height: DISPLAY, transform: `scale(${scale})`, transformOrigin: 'top left' }}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0" style={{ width: DISPLAY, height: DISPLAY }}>
|
||||
{photo}
|
||||
</div>
|
||||
<div className="absolute" style={{ left: stageLeft, top: stageTop, width: stageW, height: stageH }}>
|
||||
<Stage
|
||||
ref={stageRef}
|
||||
width={stageW}
|
||||
height={stageH}
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.target.getStage()) setSelectedId(null);
|
||||
}}
|
||||
>
|
||||
<Layer>
|
||||
<Rect
|
||||
name="printGuide"
|
||||
x={0}
|
||||
y={0}
|
||||
width={stageW}
|
||||
height={stageH}
|
||||
stroke="#E5227E"
|
||||
dash={[10, 6]}
|
||||
strokeWidth={3}
|
||||
shadowColor="#E5227E"
|
||||
shadowBlur={12}
|
||||
shadowOpacity={0.5}
|
||||
listening={false}
|
||||
/>
|
||||
<Rect
|
||||
name="printGuide"
|
||||
x={0}
|
||||
y={0}
|
||||
width={stageW}
|
||||
height={stageH}
|
||||
fill="#E5227E"
|
||||
opacity={0.07}
|
||||
listening={false}
|
||||
/>
|
||||
{elements.map((el) =>
|
||||
el.type === 'text' ? (
|
||||
<Text
|
||||
key={el.id}
|
||||
ref={(node) => {
|
||||
if (node) nodeRefs.current.set(el.id, node);
|
||||
}}
|
||||
x={el.x}
|
||||
y={el.y}
|
||||
text={el.text}
|
||||
fontFamily={el.fontFamily}
|
||||
fontSize={el.fontSize}
|
||||
fill={el.fill}
|
||||
rotation={el.rotation}
|
||||
draggable
|
||||
listening={true}
|
||||
onClick={() => setSelectedId(el.id)}
|
||||
onTap={() => setSelectedId(el.id)}
|
||||
onDragEnd={(e) => updateElement(el.id, { x: e.target.x(), y: e.target.y() })}
|
||||
onTransformEnd={(e) => {
|
||||
const node = e.target;
|
||||
updateElement(el.id, {
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
rotation: node.rotation(),
|
||||
fontSize: Math.max(8, el.fontSize * node.scaleX()),
|
||||
});
|
||||
node.scaleX(1);
|
||||
node.scaleY(1);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<UploadedImage
|
||||
key={el.id}
|
||||
el={el}
|
||||
onSelect={() => setSelectedId(el.id)}
|
||||
onChange={(attrs) => updateElement(el.id, attrs)}
|
||||
shapeRef={(node) => {
|
||||
if (node && nodeRefs.current.get(el.id) !== node) {
|
||||
nodeRefs.current.set(el.id, node);
|
||||
onNodeReady();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<Transformer
|
||||
ref={transformerRef}
|
||||
rotateEnabled
|
||||
flipEnabled={false}
|
||||
rotateAnchorOffset={30}
|
||||
anchorCornerRadius={4}
|
||||
anchorSize={8}
|
||||
borderStroke="#17181C"
|
||||
borderStrokeWidth={2}
|
||||
anchorStroke="#17181C"
|
||||
anchorFill="#FFF"
|
||||
boundBoxFunc={(oldBox, newBox) => (newBox.width < 8 || newBox.height < 8 ? oldBox : newBox)}
|
||||
/>
|
||||
</Layer>
|
||||
</Stage>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="tag-label mt-3">{caption}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
||||
const [color, setColor] = useState(product.colors[0]);
|
||||
const [size, setSize] = useState<string | null>(product.sizes[0] ?? null);
|
||||
const [view, setView] = useState<'front' | 'back'>('front');
|
||||
const [elementsFront, setElementsFront] = useState<DesignElement[]>([]);
|
||||
const [elementsBack, setElementsBack] = useState<DesignElement[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [justAdded, setJustAdded] = useState(false);
|
||||
const [nodeReadyTick, setNodeReadyTick] = useState(0);
|
||||
|
||||
const stageFrontRef = useRef<Konva.Stage | null>(null);
|
||||
const stageBackRef = useRef<Konva.Stage | null>(null);
|
||||
const transformerFrontRef = useRef<Konva.Transformer | null>(null);
|
||||
const transformerBackRef = useRef<Konva.Transformer | null>(null);
|
||||
const nodeRefs = useRef<Map<string, Konva.Node>>(new Map());
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const canvasContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [canvasScale, setCanvasScale] = useState(1);
|
||||
const addItem = useCart((s) => s.addItem);
|
||||
|
||||
// Konva's Stage keeps a fixed 560x560 internal pixel size (element x/y,
|
||||
// print-area math, and export resolution all key off DISPLAY) — on narrow
|
||||
// viewports we shrink it purely visually via CSS transform instead of
|
||||
// touching that coordinate system. Konva reads the container's actual
|
||||
// rendered (post-transform) bounding box for pointer math, so clicks/drags
|
||||
// still line up correctly at any scale.
|
||||
useLayoutEffect(() => {
|
||||
const el = canvasContainerRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setCanvasScale(Math.min(1, el.clientWidth / DISPLAY));
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const hasBack = Boolean(product.imageUrlBack);
|
||||
const canPersonalize = product.showOnPersonalised;
|
||||
const frontPrintArea = product.printArea;
|
||||
const backPrintArea = product.printAreaBack ?? product.printArea;
|
||||
const activePrintArea = view === 'front' ? frontPrintArea : backPrintArea;
|
||||
const activeStageW = activePrintArea.wPct * DISPLAY;
|
||||
const activeStageH = activePrintArea.hPct * DISPLAY;
|
||||
const setActiveElements = view === 'front' ? setElementsFront : setElementsBack;
|
||||
|
||||
// Keep whichever transformer actually owns the selected element in sync, and
|
||||
// clear the other one — an id only ever lives in one of the two arrays.
|
||||
// nodeReadyTick re-runs this once an uploaded image's Konva node actually
|
||||
// mounts (it loads asynchronously, unlike text, so it isn't there yet on the
|
||||
// same render where it gets selected).
|
||||
useEffect(() => {
|
||||
const frontNode = selectedId && elementsFront.some((e) => e.id === selectedId) ? nodeRefs.current.get(selectedId) : null;
|
||||
const backNode = selectedId && elementsBack.some((e) => e.id === selectedId) ? nodeRefs.current.get(selectedId) : null;
|
||||
if (transformerFrontRef.current) {
|
||||
transformerFrontRef.current.nodes(frontNode ? [frontNode] : []);
|
||||
transformerFrontRef.current.getLayer()?.draw();
|
||||
}
|
||||
if (transformerBackRef.current) {
|
||||
transformerBackRef.current.nodes(backNode ? [backNode] : []);
|
||||
transformerBackRef.current.getLayer()?.draw();
|
||||
}
|
||||
}, [selectedId, elementsFront.length, elementsBack.length, nodeReadyTick]);
|
||||
|
||||
// Canvas text can render with a fallback font if drawn before a web font finishes
|
||||
// loading — redraw once fonts are ready so custom font choices show up correctly.
|
||||
useEffect(() => {
|
||||
document.fonts?.ready.then(() => {
|
||||
stageFrontRef.current?.getLayers().forEach((layer) => layer.batchDraw());
|
||||
stageBackRef.current?.getLayers().forEach((layer) => layer.batchDraw());
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateElement = useCallback((id: string, attrs: Partial<DesignElement>) => {
|
||||
setElementsFront((prev) =>
|
||||
prev.some((el) => el.id === id) ? prev.map((el) => (el.id === id ? ({ ...el, ...attrs } as DesignElement) : el)) : prev,
|
||||
);
|
||||
setElementsBack((prev) =>
|
||||
prev.some((el) => el.id === id) ? prev.map((el) => (el.id === id ? ({ ...el, ...attrs } as DesignElement) : el)) : prev,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const addText = () => {
|
||||
const id = uuid();
|
||||
setActiveElements((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
type: 'text',
|
||||
x: activeStageW / 2 - 40,
|
||||
y: activeStageH / 2 - 10,
|
||||
rotation: 0,
|
||||
text: 'Your text',
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 28,
|
||||
fill: '#17181C',
|
||||
},
|
||||
]);
|
||||
setSelectedId(id);
|
||||
};
|
||||
|
||||
const handleUploadClick = () => fileInputRef.current?.click();
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const src = reader.result as string;
|
||||
const img = new window.Image();
|
||||
img.onload = () => {
|
||||
const maxDim = Math.min(activeStageW, activeStageH) * 0.7;
|
||||
const scale = Math.min(maxDim / img.width, maxDim / img.height, 1);
|
||||
const id = uuid();
|
||||
setActiveElements((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
type: 'image',
|
||||
x: activeStageW / 2 - (img.width * scale) / 2,
|
||||
y: activeStageH / 2 - (img.height * scale) / 2,
|
||||
rotation: 0,
|
||||
width: img.width * scale,
|
||||
height: img.height * scale,
|
||||
src,
|
||||
},
|
||||
]);
|
||||
setSelectedId(id);
|
||||
};
|
||||
img.src = src;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const deleteSelected = () => {
|
||||
if (!selectedId) return;
|
||||
setElementsFront((prev) => prev.filter((el) => el.id !== selectedId));
|
||||
setElementsBack((prev) => prev.filter((el) => el.id !== selectedId));
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const selected = elementsFront.find((el) => el.id === selectedId) ?? elementsBack.find((el) => el.id === selectedId);
|
||||
|
||||
// Calculate the effective price for made-to-order items (use personalisedPrice if available)
|
||||
const effectivePriceForPersonalised = canPersonalize
|
||||
? getEffectivePrice(
|
||||
{
|
||||
id: product.id,
|
||||
basePrice: product.basePrice,
|
||||
personalisedPrice: product.personalisedPrice,
|
||||
salePrice: product.onSale ? product.effectivePrice : null,
|
||||
saleStartsAt: null,
|
||||
saleEndsAt: null,
|
||||
},
|
||||
[],
|
||||
new Date(),
|
||||
true,
|
||||
).price
|
||||
: product.effectivePrice;
|
||||
|
||||
const handleAddToBag = () => {
|
||||
if (!canPersonalize) {
|
||||
const flatPhoto = product.colorPhotos[color] ?? product.imageUrl ?? '';
|
||||
addItem({
|
||||
cartItemId: uuid(),
|
||||
productId: product.id,
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
mockup: product.mockup,
|
||||
color,
|
||||
size,
|
||||
quantity,
|
||||
unitPrice: product.effectivePrice,
|
||||
designJson: { front: [], back: [] },
|
||||
designPreviewUrl: flatPhoto,
|
||||
designPreviewUrlBack: null,
|
||||
placementPreviewUrl: flatPhoto || null,
|
||||
placementPreviewUrlBack: product.imageUrlBack ?? null,
|
||||
});
|
||||
setJustAdded(true);
|
||||
setTimeout(() => setJustAdded(false), 2200);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedId(null); // clear transformer handles before export
|
||||
requestAnimationFrame(async () => {
|
||||
const frontStage = stageFrontRef.current;
|
||||
if (!frontStage) return;
|
||||
|
||||
// Hide the dashed guide box + tint before exporting, so the saved PNG is
|
||||
// just the design itself — transparent background, no shirt, no guide
|
||||
// marks — ready to hand off for printing.
|
||||
const frontGuides = frontStage.find('.printGuide');
|
||||
frontGuides.forEach((g) => g.hide());
|
||||
const frontDataUrl = frontStage.toDataURL({ pixelRatio: 2 });
|
||||
|
||||
const frontBox = {
|
||||
left: frontPrintArea.xPct * DISPLAY,
|
||||
top: frontPrintArea.yPct * DISPLAY,
|
||||
w: frontPrintArea.wPct * DISPLAY,
|
||||
h: frontPrintArea.hPct * DISPLAY,
|
||||
};
|
||||
const frontPhotoInfo = getPhotoInfo('front');
|
||||
const frontPlacementUrl = frontPhotoInfo
|
||||
? await composePlacementImage(
|
||||
frontPhotoInfo.url,
|
||||
frontPhotoInfo.tint ? { tint: true, color } : null,
|
||||
frontStage,
|
||||
frontBox,
|
||||
).catch(() => null)
|
||||
: null;
|
||||
frontGuides.forEach((g) => g.show());
|
||||
|
||||
let backDataUrl: string | null = null;
|
||||
let backPlacementUrl: string | null = null;
|
||||
if (hasBack && stageBackRef.current) {
|
||||
const backStage = stageBackRef.current;
|
||||
const backGuides = backStage.find('.printGuide');
|
||||
backGuides.forEach((g) => g.hide());
|
||||
backDataUrl = backStage.toDataURL({ pixelRatio: 2 });
|
||||
|
||||
const backBox = {
|
||||
left: backPrintArea.xPct * DISPLAY,
|
||||
top: backPrintArea.yPct * DISPLAY,
|
||||
w: backPrintArea.wPct * DISPLAY,
|
||||
h: backPrintArea.hPct * DISPLAY,
|
||||
};
|
||||
const backPhotoInfo = getPhotoInfo('back');
|
||||
backPlacementUrl = backPhotoInfo
|
||||
? await composePlacementImage(
|
||||
backPhotoInfo.url,
|
||||
backPhotoInfo.tint ? { tint: true, color } : null,
|
||||
backStage,
|
||||
backBox,
|
||||
).catch(() => null)
|
||||
: null;
|
||||
backGuides.forEach((g) => g.show());
|
||||
}
|
||||
|
||||
addItem({
|
||||
cartItemId: uuid(),
|
||||
productId: product.id,
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
mockup: product.mockup,
|
||||
color,
|
||||
size,
|
||||
quantity,
|
||||
unitPrice: effectivePriceForPersonalised,
|
||||
designJson: { front: elementsFront, back: elementsBack },
|
||||
designPreviewUrl: frontDataUrl,
|
||||
designPreviewUrlBack: backDataUrl,
|
||||
placementPreviewUrl: frontPlacementUrl ?? frontDataUrl,
|
||||
placementPreviewUrlBack: backPlacementUrl ?? backDataUrl,
|
||||
});
|
||||
setJustAdded(true);
|
||||
setTimeout(() => setJustAdded(false), 2200);
|
||||
});
|
||||
};
|
||||
|
||||
function renderPhoto(v: 'front' | 'back') {
|
||||
const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color];
|
||||
if (colorPhoto) {
|
||||
return <img src={colorPhoto} alt={`${product.name} in ${color}`} className="h-full w-full object-cover" />;
|
||||
}
|
||||
|
||||
const activePhoto = v === 'front' ? product.imageUrl : product.imageUrlBack;
|
||||
if (!activePhoto) {
|
||||
return <ProductMockup mockup={product.mockup} color={color} />;
|
||||
}
|
||||
if (product.photoRecolorable) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<img
|
||||
src={activePhoto}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover"
|
||||
style={{ filter: 'grayscale(1) brightness(2.1) contrast(1.15)' }}
|
||||
/>
|
||||
<div className="absolute inset-0" style={{ backgroundColor: color, mixBlendMode: 'multiply' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <img src={activePhoto} alt={product.name} className="h-full w-full object-cover" />;
|
||||
}
|
||||
|
||||
// Same resolution logic as renderPhoto, but returns raw data instead of JSX —
|
||||
// used when compositing the placement-reference image for the order record.
|
||||
function getPhotoInfo(v: 'front' | 'back'): { url: string; tint: boolean } | null {
|
||||
const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color];
|
||||
if (colorPhoto) return { url: colorPhoto, tint: false };
|
||||
|
||||
const activePhoto = v === 'front' ? product.imageUrl : product.imageUrlBack;
|
||||
if (!activePhoto) return null; // illustration only — nothing photographic to composite onto
|
||||
|
||||
return { url: activePhoto, tint: product.photoRecolorable };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[560px_1fr]">
|
||||
{/* Canvas */}
|
||||
<div ref={canvasContainerRef} className="w-full min-w-0 max-w-[560px]">
|
||||
{hasBack && (
|
||||
<div className="mb-3 inline-flex border border-line">
|
||||
<button
|
||||
onClick={() => setView('front')}
|
||||
className={`px-4 py-1.5 text-sm ${view === 'front' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
|
||||
>
|
||||
Front
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('back')}
|
||||
className={`px-4 py-1.5 text-sm ${view === 'back' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canPersonalize ? (
|
||||
<>
|
||||
<PrintSurface
|
||||
photo={renderPhoto('front')}
|
||||
printArea={frontPrintArea}
|
||||
elements={elementsFront}
|
||||
selectedId={selectedId}
|
||||
setSelectedId={setSelectedId}
|
||||
updateElement={updateElement}
|
||||
nodeRefs={nodeRefs}
|
||||
stageRef={stageFrontRef}
|
||||
transformerRef={transformerFrontRef}
|
||||
visible={view === 'front'}
|
||||
caption="Dashed line marks the print area"
|
||||
onNodeReady={() => setNodeReadyTick((t) => t + 1)}
|
||||
scale={canvasScale}
|
||||
/>
|
||||
|
||||
{hasBack && (
|
||||
<PrintSurface
|
||||
photo={renderPhoto('back')}
|
||||
printArea={backPrintArea}
|
||||
elements={elementsBack}
|
||||
selectedId={selectedId}
|
||||
setSelectedId={setSelectedId}
|
||||
updateElement={updateElement}
|
||||
nodeRefs={nodeRefs}
|
||||
stageRef={stageBackRef}
|
||||
transformerRef={transformerBackRef}
|
||||
visible={view === 'back'}
|
||||
caption="Dashed line marks the print area (back)"
|
||||
onNodeReady={() => setNodeReadyTick((t) => t + 1)}
|
||||
scale={canvasScale}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="relative aspect-square w-full max-w-[560px] border border-line bg-surface">
|
||||
{renderPhoto(view)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-col gap-8">
|
||||
<div>
|
||||
<h1 className="font-display text-4xl">{product.name}</h1>
|
||||
<p className="mt-2 text-muted">{product.description}</p>
|
||||
<p className="mt-4 flex items-baseline gap-2 font-mono text-lg">
|
||||
{(() => {
|
||||
const { onSale, price, originalPrice } = getEffectivePrice(
|
||||
{
|
||||
id: product.id,
|
||||
basePrice: product.basePrice,
|
||||
personalisedPrice: product.personalisedPrice,
|
||||
salePrice: product.onSale ? product.effectivePrice : null,
|
||||
saleStartsAt: null,
|
||||
saleEndsAt: null,
|
||||
},
|
||||
[],
|
||||
new Date(),
|
||||
true,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{onSale && originalPrice != null && (
|
||||
<Price cents={originalPrice} className="text-base text-muted line-through" />
|
||||
)}
|
||||
<Price cents={price} className={onSale ? 'text-splash-pink' : undefined} />
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-3">Color</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.colors.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
aria-label={`Choose color ${c}`}
|
||||
className={`h-9 w-9 rounded-full border transition-transform ${
|
||||
color === c ? 'scale-110 border-ink ring-2 ring-ink ring-offset-2 ring-offset-paper' : 'border-line'
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{product.imageUrl && !product.photoRecolorable && Object.keys(product.colorPhotos).length === 0 && (
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
This product uses a real photo, so the picture won't change color — your pick is
|
||||
still saved with the order.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{product.sizes.length > 0 && (
|
||||
<div>
|
||||
<p className="tag-label mb-3">Size</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.sizes.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSize(s)}
|
||||
className={`min-w-[44px] border px-3 py-2 text-sm transition-colors ${
|
||||
size === s
|
||||
? 'border-clay bg-clay text-paper'
|
||||
: 'border-line text-ink hover:border-clay hover:text-clay'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canPersonalize && (
|
||||
<div>
|
||||
<p className="tag-label mb-3">
|
||||
Your design {hasBack && <span className="normal-case text-muted">— editing the {view}</span>}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button onClick={addText} className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper">
|
||||
+ Add text
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUploadClick}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
+ Upload image
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
|
||||
{selected && (
|
||||
<button
|
||||
onClick={deleteSelected}
|
||||
className="border border-line px-4 py-2 text-sm text-muted hover:border-ink hover:text-ink"
|
||||
>
|
||||
Delete selected
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected?.type === 'text' && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 border border-line bg-surface p-4">
|
||||
<input
|
||||
value={selected.text}
|
||||
onChange={(e) => updateElement(selected.id, { text: e.target.value })}
|
||||
className="border border-line bg-paper px-2 py-1 text-sm text-ink placeholder:text-muted"
|
||||
placeholder="Text"
|
||||
/>
|
||||
<select
|
||||
value={selected.fontFamily}
|
||||
onChange={(e) => updateElement(selected.id, { fontFamily: e.target.value })}
|
||||
className="border border-line bg-paper px-2 py-1 text-sm text-ink"
|
||||
style={{ fontFamily: selected.fontFamily }}
|
||||
>
|
||||
{FONTS.map((f) => (
|
||||
<option key={f} value={f} style={{ fontFamily: f }}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="color"
|
||||
value={selected.fill}
|
||||
onChange={(e) => updateElement(selected.id, { fill: e.target.value })}
|
||||
className="h-8 w-8 border border-line bg-paper p-0"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-muted">
|
||||
Size
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={selected.fontSize}
|
||||
onChange={(e) => updateElement(selected.id, { fontSize: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-muted">
|
||||
Angle
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={360}
|
||||
value={selected.rotation || 0}
|
||||
onChange={(e) => updateElement(selected.id, { rotation: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="w-10 text-right font-mono text-xs">{selected.rotation || 0}°</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected?.type === 'image' && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 border border-line bg-surface p-4">
|
||||
<label className="flex items-center gap-2 text-sm text-muted">
|
||||
Angle
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={360}
|
||||
value={selected.rotation || 0}
|
||||
onChange={(e) => updateElement(selected.id, { rotation: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="w-10 text-right font-mono text-xs">{selected.rotation || 0}°</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center border border-ink">
|
||||
<button
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
className="px-3 py-2 text-sm"
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-10 text-center font-mono text-sm">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity((q) => q + 1)}
|
||||
className="px-3 py-2 text-sm"
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToBag}
|
||||
className="flex-1 bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
|
||||
>
|
||||
Add to bag — <Price cents={product.effectivePrice * quantity} />
|
||||
</button>
|
||||
</div>
|
||||
{justAdded && <p className="text-sm text-pine">Added to your bag.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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">
|
||||
<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">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Image src="/logo.png" alt="Craft2Prints" width={64} height={64} className="h-14 w-14 object-contain" />
|
||||
<span className="font-script text-4xl leading-none">Craft2Prints</span>
|
||||
</div>
|
||||
<p className="mt-3 max-w-xs text-sm text-muted">
|
||||
Ink it. Print it. Love it. Every piece made to order, from your own design.
|
||||
</p>
|
||||
<a
|
||||
href="https://www.facebook.com/Needlestocrafts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Craft2Prints on Facebook"
|
||||
className="mt-5 inline-flex items-center gap-2 border border-line px-4 py-2.5 text-sm font-medium text-ink transition-colors hover:border-splash-blue hover:bg-splash-blue hover:text-paper"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="h-6 w-6 shrink-0">
|
||||
<path d="M22 12a10 10 0 1 0-11.5 9.87v-6.98H7.9V12h2.6V9.8c0-2.6 1.55-4.03 3.92-4.03 1.13 0 2.32.2 2.32.2v2.55h-1.3c-1.29 0-1.69.8-1.69 1.62V12h2.88l-.46 2.89h-2.42v6.98A10 10 0 0 0 22 12z" />
|
||||
</svg>
|
||||
Follow us on Facebook
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-sm text-muted">
|
||||
<p className="tag-label mb-3 text-ink">Made to order</p>
|
||||
<p>Printed after you check out — nothing sits on a shelf first.</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted">
|
||||
<p className="tag-label mb-3 text-ink">Contact</p>
|
||||
<p>sales@craft2prints.co.uk</p>
|
||||
<Link href="/contact" className="mt-2 inline-block text-clay hover:text-clay-dark">
|
||||
Contact us
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-sm text-muted">
|
||||
<p className="tag-label mb-3 text-ink">Stay in the loop</p>
|
||||
<p>New templates and the occasional offer — no spam.</p>
|
||||
<NewsletterForm />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
|
||||
<p>© {new Date().getFullYear()} Craft2Prints.</p>
|
||||
<nav className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<Link href="/privacy" className="hover:text-ink">
|
||||
Privacy
|
||||
</Link>
|
||||
<Link href="/cookies" className="hover:text-ink">
|
||||
Cookies
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-ink">
|
||||
Terms
|
||||
</Link>
|
||||
<Link href="/returns" className="hover:text-ink">
|
||||
Returns
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import CartButton from './CartButton';
|
||||
import AdminMenu from './AdminMenu';
|
||||
import AccountMenu from './AccountMenu';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import NavDropdown from './NavDropdown';
|
||||
import CurrencySelector from './CurrencySelector';
|
||||
import MobileNav from './MobileNav';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getCurrentCustomer } from '@/lib/auth';
|
||||
|
||||
export default async function Header() {
|
||||
const [categories, collections, customer] = await Promise.all([
|
||||
prisma.category.findMany({
|
||||
where: { showOnPersonalised: true },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
prisma.collection.findMany({ orderBy: { name: 'asc' } }),
|
||||
getCurrentCustomer(),
|
||||
]);
|
||||
const personalisedLinks = [
|
||||
{ href: '/made-to-order', label: 'Shop all' },
|
||||
...categories.map((c) => ({ href: `/made-to-order?category=${c.slug}`, label: c.name })),
|
||||
];
|
||||
const shopBySections = [
|
||||
{
|
||||
title: 'Occasions',
|
||||
links: collections
|
||||
.filter((c) => c.group === 'OCCASION')
|
||||
.map((c) => ({ href: `/made-to-order?collection=${c.slug}`, label: c.name })),
|
||||
},
|
||||
{
|
||||
title: 'Gifts for',
|
||||
links: collections
|
||||
.filter((c) => c.group === 'RECIPIENT')
|
||||
.map((c) => ({ href: `/made-to-order?collection=${c.slug}`, label: c.name })),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-line bg-paper/90 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-4 py-3 sm:px-6 sm:py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<MobileNav
|
||||
personalisedLinks={personalisedLinks}
|
||||
shopBySections={shopBySections}
|
||||
customer={customer ? { name: customer.name, email: customer.email } : null}
|
||||
/>
|
||||
<Link href="/" className="flex items-center gap-2 sm:gap-3">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Craft2Prints"
|
||||
width={96}
|
||||
height={96}
|
||||
className="h-12 w-12 object-contain sm:h-20 sm:w-20"
|
||||
/>
|
||||
<span className="font-script text-3xl leading-none text-ink sm:text-5xl">Craft2Prints</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="hidden items-center gap-6 md:flex">
|
||||
<NavDropdown label="Personalised" links={personalisedLinks} />
|
||||
<Link href="/products" className="tag-label hover:text-ink">
|
||||
Products
|
||||
</Link>
|
||||
<NavDropdown label="Shop by" sections={shopBySections} />
|
||||
<Link href="/gallery" className="tag-label hover:text-ink">
|
||||
Gallery
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<CurrencySelector />
|
||||
<div className="hidden md:block">
|
||||
<AccountMenu customer={customer ? { name: customer.name, email: customer.email } : null} />
|
||||
</div>
|
||||
<AdminMenu />
|
||||
<ThemeToggle />
|
||||
<CartButton />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Shared wrapper for the static legal pages (/privacy, /cookies, /terms,
|
||||
// /returns) so they stay visually identical and each page file is just content.
|
||||
export default function LegalPage({
|
||||
tag,
|
||||
title,
|
||||
lastUpdated,
|
||||
children,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
lastUpdated: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<p className="tag-label text-splash-pink">{tag}</p>
|
||||
<h1 className="mt-2 font-display text-3xl">{title}</h1>
|
||||
<p className="mt-2 text-xs text-muted">Last updated: {lastUpdated}</p>
|
||||
|
||||
<div className="mt-8 space-y-6 text-sm leading-relaxed text-ink [&_h2]:font-display [&_h2]:text-xl [&_h2]:pt-2 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5 [&_a]:text-clay hover:[&_a]:text-clay-dark [&_p]:text-muted [&_li]:text-muted">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { DEFAULT_MAINTENANCE_MESSAGE } from '@/lib/settings';
|
||||
|
||||
export default function MaintenancePage({ message }: { message: string | null }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
<div className="h-1 w-24 brand-gradient" />
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Craft2Prints"
|
||||
width={96}
|
||||
height={96}
|
||||
className="mt-8 h-24 w-24 object-contain"
|
||||
/>
|
||||
<p className="mt-4 font-script text-3xl text-splash-pink">Back soon</p>
|
||||
<h1 className="mt-2 font-display text-4xl">We're making some changes</h1>
|
||||
<p className="mt-4 max-w-md text-muted">{message?.trim() || DEFAULT_MAINTENANCE_MESSAGE}</p>
|
||||
|
||||
<Link href="/admin/login" className="mt-12 tag-label text-muted hover:text-clay">
|
||||
Admin login
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { createManualSale } from '@/app/admin/accounts/actions';
|
||||
import Price from './Price';
|
||||
|
||||
type ProductOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
basePrice: number; // pence
|
||||
colors: string[];
|
||||
sizes: string[];
|
||||
};
|
||||
|
||||
type Row = {
|
||||
key: number;
|
||||
productId: string; // '' = free-text item
|
||||
description: string;
|
||||
color: string;
|
||||
size: string;
|
||||
quantity: number;
|
||||
unitPriceInput: string; // pounds, as typed — parsed on serialize
|
||||
};
|
||||
|
||||
function todayInput() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default function ManualSaleForm({ products }: { products: ProductOption[] }) {
|
||||
const [rows, setRows] = useState<Row[]>([
|
||||
{ key: 1, productId: '', description: '', color: '', size: '', quantity: 1, unitPriceInput: '' },
|
||||
]);
|
||||
const [nextKey, setNextKey] = useState(2);
|
||||
|
||||
const updateRow = (key: number, patch: Partial<Row>) => {
|
||||
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
const pickProduct = (key: number, productId: string) => {
|
||||
const product = products.find((p) => p.id === productId);
|
||||
if (!product) {
|
||||
// switched to free text — keep whatever was typed, clear product-specific fields
|
||||
updateRow(key, { productId: '', color: '', size: '' });
|
||||
return;
|
||||
}
|
||||
updateRow(key, {
|
||||
productId,
|
||||
description: product.name,
|
||||
color: product.colors[0] ?? '',
|
||||
size: product.sizes[0] ?? '',
|
||||
unitPriceInput: (product.basePrice / 100).toFixed(2),
|
||||
});
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
setRows((prev) => [
|
||||
...prev,
|
||||
{ key: nextKey, productId: '', description: '', color: '', size: '', quantity: 1, unitPriceInput: '' },
|
||||
]);
|
||||
setNextKey((k) => k + 1);
|
||||
};
|
||||
|
||||
const removeRow = (key: number) => {
|
||||
setRows((prev) => (prev.length > 1 ? prev.filter((r) => r.key !== key) : prev));
|
||||
};
|
||||
|
||||
const pence = (input: string) => Math.max(0, Math.round(Number(input || '0') * 100));
|
||||
const total = rows.reduce((sum, r) => sum + r.quantity * pence(r.unitPriceInput), 0);
|
||||
|
||||
// What actually gets submitted — the server recomputes the total itself.
|
||||
const itemsJson = JSON.stringify(
|
||||
rows
|
||||
.filter((r) => r.description.trim())
|
||||
.map((r) => ({
|
||||
productId: r.productId || null,
|
||||
description: r.description,
|
||||
color: r.productId ? r.color || null : null,
|
||||
size: r.productId ? r.size || null : null,
|
||||
quantity: r.quantity,
|
||||
unitPrice: pence(r.unitPriceInput),
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={createManualSale} className="mt-10 space-y-6">
|
||||
<input type="hidden" name="items" value={itemsJson} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="soldAt" className="tag-label mb-2 block">
|
||||
Date of sale
|
||||
</label>
|
||||
<input
|
||||
id="soldAt"
|
||||
name="soldAt"
|
||||
type="date"
|
||||
defaultValue={todayInput()}
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="buyerName" className="tag-label mb-2 block">
|
||||
Buyer name (optional)
|
||||
</label>
|
||||
<input
|
||||
id="buyerName"
|
||||
name="buyerName"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Cash buyer at a fair? Leave blank"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="buyerEmail" className="tag-label mb-2 block">
|
||||
Buyer email (optional)
|
||||
</label>
|
||||
<input
|
||||
id="buyerEmail"
|
||||
name="buyerEmail"
|
||||
type="email"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Links to their account if they have one"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-2">Items</p>
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => {
|
||||
const product = products.find((p) => p.id === row.productId);
|
||||
return (
|
||||
<div key={row.key} className="border border-line bg-surface p-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<select
|
||||
value={row.productId}
|
||||
onChange={(e) => pickProduct(row.key, e.target.value)}
|
||||
className="border border-line bg-paper px-2 py-2 text-sm"
|
||||
aria-label="Product"
|
||||
>
|
||||
<option value="">Free-text item…</option>
|
||||
{products.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={row.description}
|
||||
onChange={(e) => updateRow(row.key, { description: e.target.value })}
|
||||
placeholder="What was sold, e.g. Custom commission"
|
||||
className="border border-line bg-paper px-3 py-2 text-sm"
|
||||
aria-label="Description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{product && product.colors.length > 0 && (
|
||||
<select
|
||||
value={row.color}
|
||||
onChange={(e) => updateRow(row.key, { color: e.target.value })}
|
||||
className="border border-line bg-paper px-2 py-2 text-sm"
|
||||
aria-label="Colour"
|
||||
>
|
||||
{product.colors.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{product && product.sizes.length > 0 && (
|
||||
<select
|
||||
value={row.size}
|
||||
onChange={(e) => updateRow(row.key, { size: e.target.value })}
|
||||
className="border border-line bg-paper px-2 py-2 text-sm"
|
||||
aria-label="Size"
|
||||
>
|
||||
{product.sizes.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<label className="flex items-center gap-1 text-sm text-muted">
|
||||
Qty
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={row.quantity}
|
||||
onChange={(e) => updateRow(row.key, { quantity: Math.max(1, Math.floor(Number(e.target.value) || 1)) })}
|
||||
className="w-16 border border-line bg-paper px-2 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-sm text-muted">
|
||||
£
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
value={row.unitPriceInput}
|
||||
onChange={(e) => updateRow(row.key, { unitPriceInput: e.target.value })}
|
||||
placeholder="0.00"
|
||||
className="w-24 border border-line bg-paper px-2 py-2 text-sm"
|
||||
aria-label="Unit price in pounds"
|
||||
/>
|
||||
</label>
|
||||
<span className="ml-auto font-mono text-sm">
|
||||
<Price cents={row.quantity * pence(row.unitPriceInput)} />
|
||||
</span>
|
||||
{rows.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(row.key)}
|
||||
className="tag-label text-muted hover:text-splash-pink"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="mt-3 border border-ink px-3 py-1.5 text-xs hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
+ Add another item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="paymentMethod" className="tag-label mb-2 block">
|
||||
Paid by
|
||||
</label>
|
||||
<select
|
||||
id="paymentMethod"
|
||||
name="paymentMethod"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="CASH">Cash</option>
|
||||
<option value="BANK_TRANSFER">Bank transfer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="notes" className="tag-label mb-2 block">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<input
|
||||
id="notes"
|
||||
name="notes"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="e.g. Spring fair, collected in person"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-line pt-4">
|
||||
<p className="font-mono text-lg">
|
||||
Total <Price cents={total} />
|
||||
</p>
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Record sale
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type NavLink = { href: string; label: string };
|
||||
type NavSection = { title: string; links: NavLink[] };
|
||||
|
||||
export default function MobileNav({
|
||||
personalisedLinks,
|
||||
shopBySections,
|
||||
customer,
|
||||
}: {
|
||||
personalisedLinks: NavLink[];
|
||||
shopBySections: NavSection[];
|
||||
customer: { name: string | null; email: string } | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
// Lock background scroll while the drawer is open, and always start closed
|
||||
// on route change (this component stays mounted across client-side nav).
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? 'hidden' : '';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setExpanded(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open menu"
|
||||
aria-expanded={open}
|
||||
className="flex h-9 w-9 items-center justify-center border border-line text-ink"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex">
|
||||
<div className="absolute inset-0 bg-black/30" onClick={close} />
|
||||
<div className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-auto bg-paper p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-script text-3xl leading-none text-ink">Craft2Prints</span>
|
||||
<button onClick={close} aria-label="Close menu" className="flex h-9 w-9 items-center justify-center border border-line text-ink">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="mt-8 flex flex-col divide-y divide-line">
|
||||
<div className="py-2">
|
||||
<button
|
||||
onClick={() => setExpanded((s) => (s === 'personalised' ? null : 'personalised'))}
|
||||
aria-expanded={expanded === 'personalised'}
|
||||
className="flex w-full items-center justify-between py-2 text-left tag-label"
|
||||
>
|
||||
Personalised
|
||||
<span className={`transition-transform ${expanded === 'personalised' ? 'rotate-180' : ''}`}>▾</span>
|
||||
</button>
|
||||
{expanded === 'personalised' && (
|
||||
<div className="flex flex-col pb-2 pl-2">
|
||||
{personalisedLinks.map((link) => (
|
||||
<Link key={link.href} href={link.href} onClick={close} className="py-2 text-sm text-muted hover:text-ink">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link href="/products" onClick={close} className="py-4 tag-label">
|
||||
Products
|
||||
</Link>
|
||||
|
||||
<div className="py-2">
|
||||
<button
|
||||
onClick={() => setExpanded((s) => (s === 'shopby' ? null : 'shopby'))}
|
||||
aria-expanded={expanded === 'shopby'}
|
||||
className="flex w-full items-center justify-between py-2 text-left tag-label"
|
||||
>
|
||||
Shop by
|
||||
<span className={`transition-transform ${expanded === 'shopby' ? 'rotate-180' : ''}`}>▾</span>
|
||||
</button>
|
||||
{expanded === 'shopby' && (
|
||||
<div className="flex flex-col gap-3 pb-2 pl-2">
|
||||
{shopBySections
|
||||
.filter((s) => s.links.length > 0)
|
||||
.map((section) => (
|
||||
<div key={section.title}>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted">{section.title}</p>
|
||||
<div className="flex flex-col">
|
||||
{section.links.map((link) => (
|
||||
<Link key={link.href} href={link.href} onClick={close} className="py-2 text-sm text-muted hover:text-ink">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{shopBySections.every((s) => s.links.length === 0) && (
|
||||
<p className="text-sm text-muted">Coming soon.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link href="/gallery" onClick={close} className="py-4 tag-label">
|
||||
Gallery
|
||||
</Link>
|
||||
|
||||
<Link href={customer ? '/account' : '/account/login'} onClick={close} className="py-4 tag-label">
|
||||
{customer ? (customer.name ?? 'My account') : 'Login'}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type NavLink = { href: string; label: string };
|
||||
type NavSection = { title: string; links: NavLink[] };
|
||||
|
||||
export default function NavDropdown({
|
||||
label,
|
||||
links,
|
||||
sections,
|
||||
}: {
|
||||
label: string;
|
||||
links?: NavLink[];
|
||||
sections?: NavSection[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
return () => document.removeEventListener('mousedown', onClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="tag-label flex items-center gap-1 hover:text-ink"
|
||||
>
|
||||
{label}
|
||||
<span className={`inline-block transition-transform ${open ? 'rotate-180' : ''}`}>▾</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full mt-2 w-52 border border-line bg-paper shadow-sm">
|
||||
{sections ? (
|
||||
sections.every((s) => s.links.length === 0) ? (
|
||||
<p className="px-4 py-3 text-sm text-muted">Coming soon.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-line">
|
||||
{sections
|
||||
.filter((s) => s.links.length > 0)
|
||||
.map((section) => (
|
||||
<div key={section.title} className="py-1">
|
||||
<p className="px-4 pt-2 text-xs font-medium uppercase tracking-wide text-muted">{section.title}</p>
|
||||
{section.links.map((link) => (
|
||||
<Link
|
||||
key={link.href + link.label}
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block px-4 py-2.5 text-sm hover:bg-surface"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : links && links.length > 0 ? (
|
||||
links.map((link) => (
|
||||
<Link
|
||||
key={link.href + link.label}
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block px-4 py-3 text-sm hover:bg-surface"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<p className="px-4 py-3 text-sm text-muted">Coming soon.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { subscribeToNewsletter } from '@/app/actions';
|
||||
import Turnstile from './Turnstile';
|
||||
|
||||
export default function NewsletterForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="mt-3 max-w-xs">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const token = String(new FormData(e.currentTarget).get('cf-turnstile-response') ?? '');
|
||||
startTransition(async () => {
|
||||
const res = await subscribeToNewsletter(email, token);
|
||||
setResult(res);
|
||||
if (res.ok) setEmail('');
|
||||
});
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
aria-label="Email address"
|
||||
className="w-full min-w-0 border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="shrink-0 bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark disabled:opacity-60"
|
||||
>
|
||||
{isPending ? '…' : 'Sign up'}
|
||||
</button>
|
||||
</div>
|
||||
<Turnstile />
|
||||
</form>
|
||||
{result && (
|
||||
<p role="status" className={`mt-2 text-xs ${result.ok ? 'text-splash-blue' : 'text-splash-pink'}`}>
|
||||
{result.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
type Box = { xPct: number; yPct: number; wPct: number; hPct: number }; // 0-100
|
||||
|
||||
const DEFAULT_BOX: Box = { xPct: 30, yPct: 25, wPct: 40, hPct: 35 };
|
||||
const MIN_SIZE = 6; // percent
|
||||
|
||||
export default function PhotoPrintAreaField({ variant = 'front' }: { variant?: 'front' | 'back' }) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [box, setBox] = useState<Box>(DEFAULT_BOX);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dragState = useRef<{
|
||||
mode: 'move' | 'resize';
|
||||
startX: number;
|
||||
startY: number;
|
||||
startBox: Box;
|
||||
} | null>(null);
|
||||
|
||||
const fieldName = variant === 'front' ? 'image' : 'imageBack';
|
||||
const suffix = variant === 'front' ? '' : 'Back';
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
setPreviewUrl(null);
|
||||
return;
|
||||
}
|
||||
setPreviewUrl(URL.createObjectURL(file));
|
||||
setBox(DEFAULT_BOX);
|
||||
};
|
||||
|
||||
const onBoxMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragState.current = { mode: 'move', startX: e.clientX, startY: e.clientY, startBox: box };
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const onResizeMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragState.current = { mode: 'resize', startX: e.clientX, startY: e.clientY, startBox: box };
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const state = dragState.current;
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!state || !rect) return;
|
||||
|
||||
const dxPct = ((e.clientX - state.startX) / rect.width) * 100;
|
||||
const dyPct = ((e.clientY - state.startY) / rect.height) * 100;
|
||||
|
||||
if (state.mode === 'move') {
|
||||
const xPct = clamp(state.startBox.xPct + dxPct, 0, 100 - state.startBox.wPct);
|
||||
const yPct = clamp(state.startBox.yPct + dyPct, 0, 100 - state.startBox.hPct);
|
||||
setBox({ ...state.startBox, xPct, yPct });
|
||||
} else {
|
||||
const wPct = clamp(state.startBox.wPct + dxPct, MIN_SIZE, 100 - state.startBox.xPct);
|
||||
const hPct = clamp(state.startBox.hPct + dyPct, MIN_SIZE, 100 - state.startBox.yPct);
|
||||
setBox({ ...state.startBox, wPct, hPct });
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
dragState.current = null;
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
id={fieldName}
|
||||
name={fieldName}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
{variant === 'front' ? (
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
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).
|
||||
</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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{previewUrl && (
|
||||
<div className="mt-4">
|
||||
<p className="tag-label mb-2">Drag the box to mark the print area</p>
|
||||
<p className="mb-2 text-xs text-muted">
|
||||
Cropped to a square here — that's the same crop the design tool uses, so the box
|
||||
lines up correctly.
|
||||
</p>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative aspect-square w-full max-w-sm select-none border border-line"
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Product preview"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
<div
|
||||
onMouseDown={onBoxMouseDown}
|
||||
className="absolute cursor-move border-2 border-dashed bg-splash-pink/10"
|
||||
style={{
|
||||
left: `${box.xPct}%`,
|
||||
top: `${box.yPct}%`,
|
||||
width: `${box.wPct}%`,
|
||||
height: `${box.hPct}%`,
|
||||
borderColor: '#E5227E',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onMouseDown={onResizeMouseDown}
|
||||
className="absolute -bottom-1.5 -right-1.5 h-4 w-4 cursor-se-resize rounded-full border border-paper bg-splash-pink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">Drag the pink handle to resize.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input type="hidden" name={`hasCustomPrintArea${suffix}`} value={previewUrl ? '1' : ''} />
|
||||
<input type="hidden" name={`printX${suffix}`} value={box.xPct / 100} />
|
||||
<input type="hidden" name={`printY${suffix}`} value={box.yPct / 100} />
|
||||
<input type="hidden" name={`printW${suffix}`} value={box.wPct / 100} />
|
||||
<input type="hidden" name={`printH${suffix}`} value={box.hPct / 100} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.min(Math.max(n, min), Math.max(min, max));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useCurrency } from '@/lib/CurrencyContext';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
|
||||
export default function Price({ cents, className }: { cents: number; className?: string }) {
|
||||
const { currency } = useCurrency();
|
||||
return <span className={className}>{formatPrice(cents, currency)}</span>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Link from 'next/link';
|
||||
import ProductMockup from './ProductMockup';
|
||||
import Price from './Price';
|
||||
import type { ProductDTO } from '@/lib/types';
|
||||
|
||||
const NEW_WINDOW_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
|
||||
|
||||
export default function ProductCard({
|
||||
product,
|
||||
hrefBase = '/made-to-order',
|
||||
}: {
|
||||
product: ProductDTO;
|
||||
hrefBase?: string;
|
||||
}) {
|
||||
const thumbnail = product.colorPhotos[product.colors[0]] ?? product.imageUrl;
|
||||
const isNew = Date.now() - new Date(product.createdAt).getTime() < NEW_WINDOW_MS;
|
||||
|
||||
return (
|
||||
<Link href={`${hrefBase}/${product.slug}`} className="group block">
|
||||
<div className="relative border border-line bg-surface p-6 shadow-sm transition-all duration-300 group-hover:-translate-y-1 group-hover:border-ink group-hover:shadow-lg">
|
||||
{isNew && (
|
||||
<span className="tag-label absolute left-3 top-3 rounded-full bg-splash-blue/10 px-2.5 py-1 text-splash-blue">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
{product.onSale && (
|
||||
<span className="tag-label absolute right-3 top-3 rounded-full bg-splash-pink/10 px-2.5 py-1 text-splash-pink">
|
||||
Sale
|
||||
</span>
|
||||
)}
|
||||
<div className="mx-auto aspect-square w-full max-w-[280px] overflow-hidden">
|
||||
{thumbnail ? (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-contain transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="transition-transform duration-300 group-hover:scale-105">
|
||||
<ProductMockup mockup={product.mockup} color={product.colors[0]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end gap-2 text-right">
|
||||
{product.onSale && product.originalPrice != null && (
|
||||
<Price cents={product.originalPrice} className="font-mono text-sm text-muted line-through" />
|
||||
)}
|
||||
<Price
|
||||
cents={product.effectivePrice}
|
||||
className={`font-mono text-sm ${product.onSale ? 'text-splash-pink' : 'text-muted'}`}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import { useId } from 'react';
|
||||
|
||||
type Props = {
|
||||
mockup: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const STROKE = '#17181C';
|
||||
|
||||
/**
|
||||
* Shading is done as color-independent overlays (white/black gradients with
|
||||
* blend modes) on top of the flat garment fill, so any hex color still reads
|
||||
* as "lit from one side" without us having to compute per-color shades.
|
||||
*/
|
||||
function Shading({ clipId, variant }: { clipId: string; variant: 'garment' | 'round' }) {
|
||||
const hi = `${clipId}-hi`;
|
||||
const sh = `${clipId}-sh`;
|
||||
return (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient id={hi} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#FFFFFF" stopOpacity={variant === 'round' ? 0.55 : 0.35} />
|
||||
<stop offset="45%" stopColor="#FFFFFF" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id={sh} x1="100%" y1="100%" x2="0%" y2="0%">
|
||||
<stop offset="0%" stopColor="#000000" stopOpacity={variant === 'round' ? 0.28 : 0.16} />
|
||||
<stop offset="45%" stopColor="#000000" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
<rect x="0" y="0" width="500" height="500" fill={`url(#${hi})`} style={{ mixBlendMode: 'screen' }} />
|
||||
<rect x="0" y="0" width="500" height="500" fill={`url(#${sh})`} style={{ mixBlendMode: 'multiply' }} />
|
||||
</g>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TShirt({ color }: { color: string }) {
|
||||
const id = useId();
|
||||
const clipId = `tee-${id}`;
|
||||
const body =
|
||||
'M170 92 L112 128 L68 192 L118 232 L150 204 L150 424 C150 434 158 442 168 442 L332 442 C342 442 350 434 350 424 L350 204 L382 232 L432 192 L388 128 L330 92 C330 92 316 122 250 122 C184 122 170 92 170 92 Z';
|
||||
return (
|
||||
<svg viewBox="0 0 500 500" className="h-full w-full">
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<path d={body} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path d={body} fill={color} stroke={STROKE} strokeWidth="2.5" strokeLinejoin="round" />
|
||||
|
||||
{/* fold shadows */}
|
||||
<g clipPath={`url(#${clipId})`} opacity="0.5">
|
||||
<path d="M195 210 C 200 300, 195 380, 205 435" fill="none" stroke="#000" strokeOpacity="0.08" strokeWidth="10" />
|
||||
<path d="M310 210 C 305 300, 312 380, 300 435" fill="none" stroke="#000" strokeOpacity="0.08" strokeWidth="10" />
|
||||
<path d="M150 220 L150 340" stroke="#000" strokeOpacity="0.12" strokeWidth="4" />
|
||||
<path d="M350 220 L350 340" stroke="#000" strokeOpacity="0.12" strokeWidth="4" />
|
||||
</g>
|
||||
|
||||
<Shading clipId={clipId} variant="garment" />
|
||||
|
||||
{/* ribbed crew neck */}
|
||||
<path
|
||||
d="M330 92 C 330 92 316 122 250 122 C 184 122 170 92 170 92"
|
||||
fill="none"
|
||||
stroke={STROKE}
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<path
|
||||
d="M320 98 C 300 118 300 128 250 128 C 200 128 200 118 180 98"
|
||||
fill="none"
|
||||
stroke={STROKE}
|
||||
strokeOpacity="0.45"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* sleeve hem topstitch */}
|
||||
<path d="M112 128 L68 192 L118 232" fill="none" stroke={STROKE} strokeOpacity="0.4" strokeWidth="1.2" strokeDasharray="3 3" />
|
||||
<path d="M330 92 L388 128 L432 192 L382 232" fill="none" stroke={STROKE} strokeOpacity="0.4" strokeWidth="1.2" strokeDasharray="3 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Hoodie({ color }: { color: string }) {
|
||||
const id = useId();
|
||||
const clipId = `hoodie-${id}`;
|
||||
const body =
|
||||
'M178 100 L118 138 L78 198 L128 238 L158 212 L158 250 C 132 262 120 288 120 322 L120 432 C120 440 126 446 134 446 L366 446 C374 446 380 440 380 432 L380 322 C380 288 368 262 342 250 L342 212 L372 238 L422 198 L382 138 L322 100 C322 100 304 124 250 124 C196 124 178 100 178 100 Z';
|
||||
return (
|
||||
<svg viewBox="0 0 500 500" className="h-full w-full">
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<path d={body} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path d={body} fill={color} stroke={STROKE} strokeWidth="2.5" strokeLinejoin="round" />
|
||||
|
||||
<g clipPath={`url(#${clipId})`} opacity="0.5">
|
||||
<path d="M200 260 C 205 330, 200 390, 208 440" fill="none" stroke="#000" strokeOpacity="0.08" strokeWidth="10" />
|
||||
<path d="M300 260 C 295 330, 300 390, 292 440" fill="none" stroke="#000" strokeOpacity="0.08" strokeWidth="10" />
|
||||
</g>
|
||||
|
||||
<Shading clipId={clipId} variant="garment" />
|
||||
|
||||
{/* hood */}
|
||||
<path
|
||||
d="M195 252 C 195 288 216 304 250 304 C 284 304 305 288 305 252"
|
||||
fill="none"
|
||||
stroke={STROKE}
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<path
|
||||
d="M205 250 C 205 278 222 292 250 292 C 278 292 295 278 295 250"
|
||||
fill="none"
|
||||
stroke={STROKE}
|
||||
strokeOpacity="0.4"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
{/* drawstrings */}
|
||||
<circle cx="228" cy="262" r="3.2" fill="none" stroke={STROKE} strokeWidth="1.3" />
|
||||
<circle cx="272" cy="262" r="3.2" fill="none" stroke={STROKE} strokeWidth="1.3" />
|
||||
<path d="M228 265 C 226 292 232 305 226 320" fill="none" stroke={STROKE} strokeWidth="1.3" />
|
||||
<path d="M272 265 C 274 292 268 305 274 320" fill="none" stroke={STROKE} strokeWidth="1.3" />
|
||||
|
||||
{/* kangaroo pocket */}
|
||||
<path
|
||||
d="M172 345 C 172 380 200 395 250 395 C 300 395 328 380 328 345"
|
||||
fill="none"
|
||||
stroke={STROKE}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path d="M250 345 L250 365" fill="none" stroke={STROKE} strokeOpacity="0.35" strokeWidth="1.3" />
|
||||
|
||||
{/* cuffs + hem ribbing */}
|
||||
<path d="M158 340 L158 400 M342 340 L342 400" stroke={STROKE} strokeWidth="1.6" fill="none" />
|
||||
<path d="M124 428 L376 428" stroke={STROKE} strokeOpacity="0.35" strokeWidth="1.3" strokeDasharray="2 4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Mug({ color }: { color: string }) {
|
||||
const id = useId();
|
||||
const clipId = `mug-${id}`;
|
||||
const body = 'M142 158 L358 158 L347 402 C347 418 332 428 316 428 L184 428 C168 428 153 418 153 402 Z';
|
||||
return (
|
||||
<svg viewBox="0 0 500 500" className="h-full w-full">
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<path d={body} />
|
||||
</clipPath>
|
||||
<radialGradient id={`${clipId}-rim`} cx="50%" cy="50%" r="50%">
|
||||
<stop offset="60%" stopColor="#000" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="#000" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient id={`${clipId}-handle`} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#FFF" stopOpacity="0.5" />
|
||||
<stop offset="100%" stopColor="#000" stopOpacity="0.15" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path d={body} fill={color} stroke={STROKE} strokeWidth="2.5" strokeLinejoin="round" />
|
||||
<Shading clipId={clipId} variant="round" />
|
||||
|
||||
{/* handle — double curve for thickness + inner shading */}
|
||||
<path
|
||||
d="M352 196 C 415 196 440 228 440 262 C 440 296 415 328 352 328"
|
||||
fill="none"
|
||||
stroke={STROKE}
|
||||
strokeWidth="14"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M352 196 C 415 196 440 228 440 262 C 440 296 415 328 352 328"
|
||||
fill="none"
|
||||
stroke={`url(#${clipId}-handle)`}
|
||||
strokeWidth="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* rim / opening */}
|
||||
<ellipse cx="250" cy="158" rx="108" ry="16" fill={color} stroke={STROKE} strokeWidth="2.5" />
|
||||
<ellipse cx="250" cy="158" rx="108" ry="16" fill={`url(#${clipId}-rim)`} />
|
||||
<ellipse cx="250" cy="155" rx="92" ry="11" fill="none" stroke={STROKE} strokeOpacity="0.3" strokeWidth="1.2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Tumbler({ color }: { color: string }) {
|
||||
const id = useId();
|
||||
const clipId = `tumbler-${id}`;
|
||||
const body = 'M188 148 L312 148 L296 424 C296 438 282 448 267 448 L233 448 C218 448 204 438 204 424 Z';
|
||||
return (
|
||||
<svg viewBox="0 0 500 500" className="h-full w-full">
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<path d={body} />
|
||||
</clipPath>
|
||||
<linearGradient id={`${clipId}-lid`} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#F2F2F2" />
|
||||
<stop offset="45%" stopColor="#CBCBCB" />
|
||||
<stop offset="100%" stopColor="#9A9A9A" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* lid */}
|
||||
<path
|
||||
d="M198 100 L302 100 L308 140 L192 140 Z"
|
||||
fill={`url(#${clipId}-lid)`}
|
||||
stroke={STROKE}
|
||||
strokeWidth="2.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect x="228" y="86" width="44" height="18" rx="6" fill={`url(#${clipId}-lid)`} stroke={STROKE} strokeWidth="1.8" />
|
||||
|
||||
{/* body */}
|
||||
<path d={body} fill={color} stroke={STROKE} strokeWidth="2.5" strokeLinejoin="round" />
|
||||
<Shading clipId={clipId} variant="round" />
|
||||
|
||||
{/* seam ring near lid + base */}
|
||||
<path d="M192 168 L308 168" stroke={STROKE} strokeOpacity="0.3" strokeWidth="1.3" />
|
||||
<path d="M206 420 L294 420" stroke={STROKE} strokeOpacity="0.3" strokeWidth="1.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneCase({ color }: { color: string }) {
|
||||
const id = useId();
|
||||
const clipId = `case-${id}`;
|
||||
return (
|
||||
<svg viewBox="0 0 500 500" className="h-full w-full">
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<rect x="150" y="70" width="200" height="400" rx="34" />
|
||||
</clipPath>
|
||||
<radialGradient id={`${clipId}-lens`} cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0%" stopColor="#5a5a5a" />
|
||||
<stop offset="60%" stopColor="#1a1a1a" />
|
||||
<stop offset="100%" stopColor="#000" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="150" y="70" width="200" height="400" rx="34" fill={color} stroke={STROKE} strokeWidth="2.5" />
|
||||
<Shading clipId={clipId} variant="garment" />
|
||||
|
||||
{/* camera bump */}
|
||||
<rect x="180" y="100" width="66" height="66" rx="16" fill="#00000022" stroke={STROKE} strokeWidth="1.6" />
|
||||
<circle cx="201" cy="121" r="11" fill={`url(#${clipId}-lens)`} stroke={STROKE} strokeWidth="1.2" />
|
||||
<circle cx="197" cy="117" r="2.4" fill="#fff" fillOpacity="0.6" />
|
||||
<circle cx="227" cy="143" r="11" fill={`url(#${clipId}-lens)`} stroke={STROKE} strokeWidth="1.2" />
|
||||
<circle cx="223" cy="139" r="2.4" fill="#fff" fillOpacity="0.6" />
|
||||
<circle cx="201" cy="148" r="7" fill={`url(#${clipId}-lens)`} stroke={STROKE} strokeWidth="1" />
|
||||
|
||||
{/* side buttons */}
|
||||
<rect x="147" y="150" width="4" height="30" rx="2" fill={STROKE} opacity="0.6" />
|
||||
<rect x="147" y="195" width="4" height="46" rx="2" fill={STROKE} opacity="0.6" />
|
||||
<rect x="349" y="170" width="4" height="40" rx="2" fill={STROKE} opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductMockup({ mockup, color }: Props) {
|
||||
switch (mockup) {
|
||||
case 'hoodie':
|
||||
return <Hoodie color={color} />;
|
||||
case 'mug':
|
||||
return <Mug color={color} />;
|
||||
case 'tumbler':
|
||||
return <Tumbler color={color} />;
|
||||
case 'phonecase':
|
||||
return <PhoneCase color={color} />;
|
||||
case 'tshirt':
|
||||
default:
|
||||
return <TShirt color={color} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function PromotionBanner() {
|
||||
const now = new Date();
|
||||
const promotion = await prisma.promotion.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{ OR: [{ startsAt: null }, { startsAt: { lte: now } }] },
|
||||
{ OR: [{ endsAt: null }, { endsAt: { gte: now } }] },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!promotion) return null;
|
||||
|
||||
return (
|
||||
<div className="brand-gradient px-6 py-2 text-center text-sm font-medium text-white">
|
||||
{promotion.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
type Product = { id: string; name: string };
|
||||
|
||||
// Checking a product auto-switches scope to "Specific items" — picking an
|
||||
// item to discount only makes sense if the promotion is actually scoped to
|
||||
// specific items, so we don't make the admin flip the radio manually too.
|
||||
export default function PromotionScopePicker({
|
||||
products,
|
||||
defaultScope = 'ALL',
|
||||
defaultSelectedIds = [],
|
||||
}: {
|
||||
products: Product[];
|
||||
defaultScope?: 'ALL' | 'SPECIFIC';
|
||||
defaultSelectedIds?: string[];
|
||||
}) {
|
||||
const [scope, setScope] = useState<'ALL' | 'SPECIFIC'>(defaultScope);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(defaultSelectedIds));
|
||||
|
||||
const toggleProduct = (id: string, checked: boolean) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
if (checked) setScope('SPECIFIC');
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="tag-label mb-2 block">Applies to</legend>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="scope"
|
||||
value="ALL"
|
||||
checked={scope === 'ALL'}
|
||||
onChange={() => setScope('ALL')}
|
||||
className="h-4 w-4 accent-clay"
|
||||
/>
|
||||
All items
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="scope"
|
||||
value="SPECIFIC"
|
||||
checked={scope === 'SPECIFIC'}
|
||||
onChange={() => setScope('SPECIFIC')}
|
||||
className="h-4 w-4 accent-clay"
|
||||
/>
|
||||
Specific items
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 max-h-64 space-y-2 overflow-y-auto border border-line bg-paper p-3">
|
||||
{products.map((p) => (
|
||||
<label key={p.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="productIds"
|
||||
value={p.id}
|
||||
checked={selected.has(p.id)}
|
||||
onChange={(e) => toggleProduct(p.id, e.target.checked)}
|
||||
className="h-4 w-4 accent-clay"
|
||||
/>
|
||||
{p.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">Only used when "Specific items" is selected above.</p>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { createPurchase } from '@/app/admin/accounts/purchases/actions';
|
||||
import Price from './Price';
|
||||
|
||||
type ProductOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: string[];
|
||||
sizes: string[];
|
||||
};
|
||||
|
||||
type Row = {
|
||||
key: number;
|
||||
productId: string; // '' = material / other line
|
||||
description: string;
|
||||
color: string;
|
||||
size: string;
|
||||
quantity: number;
|
||||
unitCostInput: string; // pounds as typed
|
||||
};
|
||||
|
||||
function todayInput() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default function PurchaseForm({ products }: { products: ProductOption[] }) {
|
||||
const [rows, setRows] = useState<Row[]>([
|
||||
{ key: 1, productId: '', description: '', color: '', size: '', quantity: 1, unitCostInput: '' },
|
||||
]);
|
||||
const [nextKey, setNextKey] = useState(2);
|
||||
|
||||
const updateRow = (key: number, patch: Partial<Row>) => {
|
||||
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
const pickProduct = (key: number, productId: string) => {
|
||||
const product = products.find((p) => p.id === productId);
|
||||
if (!product) {
|
||||
updateRow(key, { productId: '', color: '', size: '' });
|
||||
return;
|
||||
}
|
||||
updateRow(key, {
|
||||
productId,
|
||||
description: product.name,
|
||||
color: product.colors[0] ?? '',
|
||||
size: product.sizes[0] ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
setRows((prev) => [
|
||||
...prev,
|
||||
{ key: nextKey, productId: '', description: '', color: '', size: '', quantity: 1, unitCostInput: '' },
|
||||
]);
|
||||
setNextKey((k) => k + 1);
|
||||
};
|
||||
|
||||
const removeRow = (key: number) => {
|
||||
setRows((prev) => (prev.length > 1 ? prev.filter((r) => r.key !== key) : prev));
|
||||
};
|
||||
|
||||
const pence = (input: string) => Math.max(0, Math.round(Number(input || '0') * 100));
|
||||
const total = rows.reduce((sum, r) => sum + r.quantity * pence(r.unitCostInput), 0);
|
||||
|
||||
const itemsJson = JSON.stringify(
|
||||
rows
|
||||
.filter((r) => r.description.trim())
|
||||
.map((r) => ({
|
||||
productId: r.productId || null,
|
||||
description: r.description,
|
||||
color: r.productId ? r.color || null : null,
|
||||
size: r.productId ? r.size || null : null,
|
||||
quantity: r.quantity,
|
||||
unitCost: pence(r.unitCostInput),
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={createPurchase} className="mt-10 space-y-6" encType="multipart/form-data">
|
||||
<input type="hidden" name="items" value={itemsJson} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="supplierName" className="tag-label mb-2 block">
|
||||
Supplier
|
||||
</label>
|
||||
<input
|
||||
id="supplierName"
|
||||
name="supplierName"
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="e.g. AWD Apparel"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="purchasedAt" className="tag-label mb-2 block">
|
||||
Invoice date
|
||||
</label>
|
||||
<input
|
||||
id="purchasedAt"
|
||||
name="purchasedAt"
|
||||
type="date"
|
||||
defaultValue={todayInput()}
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="invoice" className="tag-label mb-2 block">
|
||||
Invoice file (optional)
|
||||
</label>
|
||||
<input
|
||||
id="invoice"
|
||||
name="invoice"
|
||||
type="file"
|
||||
accept="application/pdf,image/*"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">PDF or photo, up to 8 MB — kept with the purchase for your records.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-2">Items</p>
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => {
|
||||
const product = products.find((p) => p.id === row.productId);
|
||||
return (
|
||||
<div key={row.key} className="border border-line bg-surface p-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<select
|
||||
value={row.productId}
|
||||
onChange={(e) => pickProduct(row.key, e.target.value)}
|
||||
className="border border-line bg-paper px-2 py-2 text-sm"
|
||||
aria-label="Stock for product"
|
||||
>
|
||||
<option value="">Material / other (no stock count)…</option>
|
||||
{products.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={row.description}
|
||||
onChange={(e) => updateRow(row.key, { description: e.target.value })}
|
||||
placeholder="What you bought, e.g. Vinyl rolls"
|
||||
className="border border-line bg-paper px-3 py-2 text-sm"
|
||||
aria-label="Description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{product && product.colors.length > 0 && (
|
||||
<select
|
||||
value={row.color}
|
||||
onChange={(e) => updateRow(row.key, { color: e.target.value })}
|
||||
className="border border-line bg-paper px-2 py-2 text-sm"
|
||||
aria-label="Colour"
|
||||
>
|
||||
{product.colors.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{product && product.sizes.length > 0 && (
|
||||
<select
|
||||
value={row.size}
|
||||
onChange={(e) => updateRow(row.key, { size: e.target.value })}
|
||||
className="border border-line bg-paper px-2 py-2 text-sm"
|
||||
aria-label="Size"
|
||||
>
|
||||
{product.sizes.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<label className="flex items-center gap-1 text-sm text-muted">
|
||||
Qty
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={row.quantity}
|
||||
onChange={(e) => updateRow(row.key, { quantity: Math.max(1, Math.floor(Number(e.target.value) || 1)) })}
|
||||
className="w-16 border border-line bg-paper px-2 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-sm text-muted">
|
||||
£ each
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
value={row.unitCostInput}
|
||||
onChange={(e) => updateRow(row.key, { unitCostInput: e.target.value })}
|
||||
placeholder="0.00"
|
||||
className="w-24 border border-line bg-paper px-2 py-2 text-sm"
|
||||
aria-label="Unit cost in pounds"
|
||||
/>
|
||||
</label>
|
||||
<span className="ml-auto font-mono text-sm">
|
||||
<Price cents={row.quantity * pence(row.unitCostInput)} />
|
||||
</span>
|
||||
{rows.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(row.key)}
|
||||
className="tag-label text-muted hover:text-splash-pink"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="mt-3 border border-ink px-3 py-1.5 text-xs hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
+ Add another item
|
||||
</button>
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
Lines matched to a product add to its stock count. "Material / other" lines are just
|
||||
recorded — ink, packaging, anything without a shelf count.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="notes" className="tag-label mb-2 block">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<input
|
||||
id="notes"
|
||||
name="notes"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="e.g. Restock ahead of Christmas fair"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-line pt-4">
|
||||
<p className="font-mono text-lg">
|
||||
Total <Price cents={total} />
|
||||
</p>
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Record purchase
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
// Fades + slides content in the first time it scrolls into view. Respects
|
||||
// prefers-reduced-motion globally via the CSS transition itself (see globals.css).
|
||||
export default function Reveal({
|
||||
children,
|
||||
className = '',
|
||||
delayMs = 0,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15 },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`transition-all duration-700 ease-out ${
|
||||
visible ? 'translate-y-0 opacity-100' : 'translate-y-6 opacity-0'
|
||||
} ${className}`}
|
||||
style={{ transitionDelay: visible ? `${delayMs}ms` : '0ms' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Placeholder reviews so the product page doesn't look empty. Swap this out for
|
||||
// real review data (its own Prisma model, or a reviews API) once you're ready.
|
||||
const SAMPLE_REVIEWS = [
|
||||
{
|
||||
name: 'Priya S.',
|
||||
rating: 5,
|
||||
body: 'The print came out exactly how I designed it. Fabric feels heavier than I expected in a good way.',
|
||||
},
|
||||
{
|
||||
name: 'Marcus T.',
|
||||
rating: 5,
|
||||
body: "Uploaded my own logo and it lined up perfectly. Shipping was faster than the estimate too.",
|
||||
},
|
||||
{
|
||||
name: 'Aisha K.',
|
||||
rating: 4,
|
||||
body: 'Great quality overall. Wish there were a couple more color options for this one.',
|
||||
},
|
||||
];
|
||||
|
||||
function Stars({ rating }: { rating: number }) {
|
||||
return (
|
||||
<span aria-label={`${rating} out of 5 stars`} className="text-splash-orange">
|
||||
{'★'.repeat(rating)}
|
||||
<span className="text-line">{'★'.repeat(5 - rating)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Reviews() {
|
||||
const average =
|
||||
Math.round((SAMPLE_REVIEWS.reduce((s, r) => s + r.rating, 0) / SAMPLE_REVIEWS.length) * 10) / 10;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-4">
|
||||
<h2 className="font-display text-3xl">Reviews</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Stars rating={Math.round(average)} />
|
||||
<span className="text-sm text-muted">
|
||||
{average} · {SAMPLE_REVIEWS.length} reviews
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6 sm:grid-cols-3">
|
||||
{SAMPLE_REVIEWS.map((r) => (
|
||||
<div key={r.name} className="border border-line bg-surface p-5">
|
||||
<Stars rating={r.rating} />
|
||||
<p className="mt-3 text-sm text-ink">{r.body}</p>
|
||||
<p className="tag-label mt-4">{r.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const FB_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID;
|
||||
|
||||
export default function ShareButtons({
|
||||
message,
|
||||
link,
|
||||
redirectUri,
|
||||
}: {
|
||||
message: string;
|
||||
link: string;
|
||||
redirectUri: string;
|
||||
}) {
|
||||
const whatsappHref = `https://wa.me/?text=${encodeURIComponent(message)}`;
|
||||
const messengerHref = FB_APP_ID
|
||||
? `https://www.facebook.com/dialog/send?link=${encodeURIComponent(link)}&app_id=${FB_APP_ID}&redirect_uri=${encodeURIComponent(redirectUri)}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<a
|
||||
href={whatsappHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border border-ink px-4 py-3 text-center text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Share via WhatsApp
|
||||
</a>
|
||||
|
||||
{messengerHref ? (
|
||||
<a
|
||||
href={messengerHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border border-ink px-4 py-3 text-center text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Share via Messenger
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-center text-xs text-muted">
|
||||
Messenger sharing needs a Facebook App ID — see the README's "Sharing via
|
||||
Messenger" section.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
const PRESETS = ['XS', 'S', 'M', 'L', 'XL', 'XXL'];
|
||||
|
||||
export default function SizePicker({ name, defaultSizes = [] }: { name: string; defaultSizes?: string[] }) {
|
||||
const [sizes, setSizes] = useState<string[]>(defaultSizes);
|
||||
const [custom, setCustom] = useState('');
|
||||
|
||||
const addSize = (s: string) => {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed || sizes.includes(trimmed)) return;
|
||||
setSizes((prev) => [...prev, trimmed]);
|
||||
};
|
||||
|
||||
const removeSize = (s: string) => {
|
||||
setSizes((prev) => prev.filter((x) => x !== s));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input type="hidden" name={name} value={sizes.join(',')} />
|
||||
|
||||
{sizes.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{sizes.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="flex items-center gap-1.5 border border-ink bg-ink px-3 py-1 text-xs text-paper"
|
||||
>
|
||||
{s}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSize(s)}
|
||||
aria-label={`Remove ${s}`}
|
||||
className="hover:text-splash-pink"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESETS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => addSize(s)}
|
||||
disabled={sizes.includes(s)}
|
||||
className="border border-line px-3 py-1.5 text-xs hover:border-clay hover:text-clay disabled:opacity-30"
|
||||
>
|
||||
+ {s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<input
|
||||
value={custom}
|
||||
onChange={(e) => setCustom(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addSize(custom);
|
||||
setCustom('');
|
||||
}
|
||||
}}
|
||||
placeholder="Custom size (e.g. One Size, 9, 10)"
|
||||
className="border border-line bg-paper px-2 py-1.5 text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
addSize(custom);
|
||||
setCustom('');
|
||||
}}
|
||||
className="border border-ink px-3 py-1.5 text-xs hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">Leave empty for products that don't need sizing.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import ProductMockup from './ProductMockup';
|
||||
import Price from './Price';
|
||||
import { useCart } from '@/lib/cartStore';
|
||||
import type { ProductDTO } from '@/lib/types';
|
||||
|
||||
export default function StandardProductView({ product }: { product: ProductDTO }) {
|
||||
const [color, setColor] = useState(product.colors[0]);
|
||||
const [size, setSize] = useState<string | null>(product.sizes[0] ?? null);
|
||||
const [view, setView] = useState<'front' | 'back'>('front');
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [justAdded, setJustAdded] = useState(false);
|
||||
const addItem = useCart((s) => s.addItem);
|
||||
|
||||
const hasBack = Boolean(product.imageUrlBack);
|
||||
|
||||
function renderPhoto(v: 'front' | 'back') {
|
||||
const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color];
|
||||
if (colorPhoto) {
|
||||
return <img src={colorPhoto} alt={`${product.name} in ${color}`} className="h-full w-full object-cover" />;
|
||||
}
|
||||
|
||||
const activePhoto = v === 'front' ? product.imageUrl : product.imageUrlBack;
|
||||
if (!activePhoto) {
|
||||
return <ProductMockup mockup={product.mockup} color={color} />;
|
||||
}
|
||||
if (product.photoRecolorable) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<img
|
||||
src={activePhoto}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover"
|
||||
style={{ filter: 'grayscale(1) brightness(2.1) contrast(1.15)' }}
|
||||
/>
|
||||
<div className="absolute inset-0" style={{ backgroundColor: color, mixBlendMode: 'multiply' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <img src={activePhoto} alt={product.name} className="h-full w-full object-cover" />;
|
||||
}
|
||||
|
||||
const handleAddToBag = () => {
|
||||
addItem({
|
||||
cartItemId: uuid(),
|
||||
productId: product.id,
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
mockup: product.mockup,
|
||||
color,
|
||||
size,
|
||||
quantity,
|
||||
unitPrice: product.effectivePrice,
|
||||
designJson: { front: [], back: [] },
|
||||
designPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? '',
|
||||
designPreviewUrlBack: product.imageUrlBack ?? null,
|
||||
placementPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? null,
|
||||
placementPreviewUrlBack: product.imageUrlBack ?? null,
|
||||
});
|
||||
setJustAdded(true);
|
||||
setTimeout(() => setJustAdded(false), 2200);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[560px_1fr]">
|
||||
{/* Photo */}
|
||||
<div className="w-full min-w-0 max-w-[560px]">
|
||||
{hasBack && (
|
||||
<div className="mb-3 inline-flex border border-line">
|
||||
<button
|
||||
onClick={() => setView('front')}
|
||||
className={`px-4 py-1.5 text-sm ${view === 'front' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
|
||||
>
|
||||
Front
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('back')}
|
||||
className={`px-4 py-1.5 text-sm ${view === 'back' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative aspect-square w-full border border-line bg-surface">
|
||||
{renderPhoto(view)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-col gap-8">
|
||||
<div>
|
||||
<h1 className="font-display text-4xl">{product.name}</h1>
|
||||
<p className="mt-2 text-muted">{product.description}</p>
|
||||
<p className="mt-4 flex items-baseline gap-2 font-mono text-lg">
|
||||
{product.onSale && product.originalPrice != null && (
|
||||
<Price cents={product.originalPrice} className="text-base text-muted line-through" />
|
||||
)}
|
||||
<Price cents={product.effectivePrice} className={product.onSale ? 'text-splash-pink' : undefined} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-3">Color</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.colors.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
aria-label={`Choose color ${c}`}
|
||||
className={`h-9 w-9 rounded-full border transition-transform ${
|
||||
color === c ? 'scale-110 border-ink ring-2 ring-ink ring-offset-2 ring-offset-paper' : 'border-line'
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{product.imageUrl && !product.photoRecolorable && Object.keys(product.colorPhotos).length === 0 && (
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
This product uses a real photo, so the picture won't change color — your pick is
|
||||
still saved with the order.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{product.sizes.length > 0 && (
|
||||
<div>
|
||||
<p className="tag-label mb-3">Size</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.sizes.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSize(s)}
|
||||
className={`min-w-[44px] border px-3 py-2 text-sm transition-colors ${
|
||||
size === s
|
||||
? 'border-clay bg-clay text-paper'
|
||||
: 'border-line text-ink hover:border-clay hover:text-clay'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center border border-ink">
|
||||
<button
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
className="px-3 py-2 text-sm"
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-10 text-center font-mono text-sm">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity((q) => q + 1)}
|
||||
className="px-3 py-2 text-sm"
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToBag}
|
||||
className="flex-1 bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
|
||||
>
|
||||
Add to bag — <Price cents={product.effectivePrice * quantity} />
|
||||
</button>
|
||||
</div>
|
||||
{justAdded && <p className="text-sm text-pine">Added to your bag.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { setStockQuantity } from '@/app/admin/accounts/purchases/actions';
|
||||
|
||||
type ProductOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: string[];
|
||||
sizes: string[];
|
||||
};
|
||||
|
||||
// "I counted the shelf" — set the absolute quantity for any product/colour/size
|
||||
// combo, including ones with no stock row yet (initial stock take).
|
||||
export default function StockAdjustForm({ products }: { products: ProductOption[] }) {
|
||||
const [productId, setProductId] = useState(products[0]?.id ?? '');
|
||||
const product = products.find((p) => p.id === productId);
|
||||
const [color, setColor] = useState(product?.colors[0] ?? '');
|
||||
const [size, setSize] = useState(product?.sizes[0] ?? '');
|
||||
|
||||
const pick = (id: string) => {
|
||||
setProductId(id);
|
||||
const next = products.find((p) => p.id === id);
|
||||
setColor(next?.colors[0] ?? '');
|
||||
setSize(next?.sizes[0] ?? '');
|
||||
};
|
||||
|
||||
if (products.length === 0) return null;
|
||||
|
||||
return (
|
||||
<form action={setStockQuantity} className="mt-6 flex flex-wrap items-end gap-2 border border-line bg-surface p-4">
|
||||
<input type="hidden" name="productId" value={productId} />
|
||||
<input type="hidden" name="color" value={color} />
|
||||
<input type="hidden" name="size" value={size} />
|
||||
<div>
|
||||
<label className="tag-label mb-1 block">Product</label>
|
||||
<select
|
||||
value={productId}
|
||||
onChange={(e) => pick(e.target.value)}
|
||||
className="border border-line bg-paper px-2 py-2 text-sm"
|
||||
>
|
||||
{products.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{product && product.colors.length > 0 && (
|
||||
<div>
|
||||
<label className="tag-label mb-1 block">Colour</label>
|
||||
<select
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="border border-line bg-paper px-2 py-2 text-sm"
|
||||
>
|
||||
{product.colors.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{product && product.sizes.length > 0 && (
|
||||
<div>
|
||||
<label className="tag-label mb-1 block">Size</label>
|
||||
<select
|
||||
value={size}
|
||||
onChange={(e) => setSize(e.target.value)}
|
||||
className="border border-line bg-paper px-2 py-2 text-sm"
|
||||
>
|
||||
{product.sizes.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="tag-label mb-1 block">Count</label>
|
||||
<input
|
||||
type="number"
|
||||
name="quantity"
|
||||
required
|
||||
className="w-24 border border-line bg-paper px-2 py-2 text-sm"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Set count
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [dark, setDark] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
setDark(document.documentElement.classList.contains('dark'));
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !dark;
|
||||
setDark(next);
|
||||
document.documentElement.classList.toggle('dark', next);
|
||||
localStorage.setItem('theme', next ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
// Avoid rendering the wrong icon before we know the real theme (hydration-safe).
|
||||
if (!mounted) {
|
||||
return <div className="h-9 w-9" aria-hidden />;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
className="flex h-9 w-9 items-center justify-center border border-line text-ink transition-colors hover:border-clay hover:text-clay"
|
||||
>
|
||||
{dark ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="4.5" />
|
||||
<path d="M12 2v2.2M12 19.8V22M4.2 4.2l1.6 1.6M18.2 18.2l1.6 1.6M2 12h2.2M19.8 12H22M4.2 19.8l1.6-1.6M18.2 5.8l1.6-1.6" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Script from 'next/script';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (container: HTMLElement, options: Record<string, unknown>) => string;
|
||||
remove: (widgetId: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Renders nothing if the site key isn't set yet (before a Cloudflare account
|
||||
// exists), so pages don't show a broken widget.
|
||||
//
|
||||
// Uses Cloudflare's *explicit* render API rather than its class-based
|
||||
// auto-render: auto-render only scans the DOM once, when the script first
|
||||
// loads. Since this component can appear on multiple pages (this page's form
|
||||
// plus the footer newsletter form present on every page), a client-side
|
||||
// route change mounts a fresh `cf-turnstile` div after that one-time scan
|
||||
// already ran — auto-render would silently never pick it up, and only a hard
|
||||
// refresh (which reloads and rescans) would work. Calling `turnstile.render()`
|
||||
// ourselves on every mount avoids that.
|
||||
//
|
||||
// The widget also verifies asynchronously after it renders, so submitting
|
||||
// immediately (before it finishes) sends an empty/stale token and the server
|
||||
// rejects it. We disable the form's submit button until Cloudflare's
|
||||
// callback confirms a token is ready, and re-disable it if the token expires.
|
||||
export default function Turnstile() {
|
||||
const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const widgetIdRef = useRef<string | null>(null);
|
||||
const [scriptReady, setScriptReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && window.turnstile) setScriptReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey || !scriptReady || !containerRef.current || !window.turnstile) return;
|
||||
|
||||
const submitButton = containerRef.current.closest('form')?.querySelector<HTMLButtonElement>('button[type="submit"]');
|
||||
if (submitButton) submitButton.disabled = true;
|
||||
|
||||
const widgetId = window.turnstile.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
callback: () => {
|
||||
if (submitButton) submitButton.disabled = false;
|
||||
},
|
||||
'expired-callback': () => {
|
||||
if (submitButton) submitButton.disabled = true;
|
||||
},
|
||||
});
|
||||
widgetIdRef.current = widgetId;
|
||||
|
||||
return () => {
|
||||
if (widgetIdRef.current && window.turnstile) window.turnstile.remove(widgetIdRef.current);
|
||||
widgetIdRef.current = null;
|
||||
};
|
||||
}, [siteKey, scriptReady]);
|
||||
|
||||
if (!siteKey) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
||||
strategy="afterInteractive"
|
||||
onReady={() => setScriptReady(true)}
|
||||
/>
|
||||
<div ref={containerRef} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user