Initial commit: Craft2Prints with Stages 1-3

This commit is contained in:
Andymick
2026-07-15 18:09:59 +01:00
commit 41937ffc15
158 changed files with 16054 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import PromotionScopePicker from '@/components/PromotionScopePicker';
import { updatePromotion } from '../../actions';
export default async function EditPromotionPage({ params }: { params: { id: string } }) {
const [promotion, products] = await Promise.all([
prisma.promotion.findUnique({ where: { id: params.id }, include: { products: { select: { id: true } } } }),
prisma.product.findMany({ orderBy: { name: 'asc' } }),
]);
if (!promotion) notFound();
const selectedIds = new Set(promotion.products.map((p) => p.id));
const toDateInput = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : '');
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Edit promotion</h1>
<p className="mt-2 text-sm text-muted">
Shown as a banner at the top of every page while its date window is active, and actually
discounts the price of whatever it covers every item, or specific ones you pick.
</p>
<form action={updatePromotion} className="mt-10 space-y-6">
<input type="hidden" name="id" value={promotion.id} />
<div>
<label htmlFor="message" className="tag-label mb-2 block">
Banner message
</label>
<textarea
id="message"
name="message"
required
rows={2}
defaultValue={promotion.message}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="discountPercent" className="tag-label mb-2 block">
Discount (%)
</label>
<input
id="discountPercent"
name="discountPercent"
type="number"
min={1}
max={100}
required
defaultValue={promotion.discountPercent}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<PromotionScopePicker
products={products}
defaultScope={promotion.scope as 'ALL' | 'SPECIFIC'}
defaultSelectedIds={[...selectedIds]}
/>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="startsAt" className="tag-label mb-2 block">
Starts (optional)
</label>
<input
id="startsAt"
name="startsAt"
type="date"
defaultValue={toDateInput(promotion.startsAt)}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">Leave blank to start immediately.</p>
</div>
<div>
<label htmlFor="endsAt" className="tag-label mb-2 block">
Ends (optional)
</label>
<input
id="endsAt"
name="endsAt"
type="date"
defaultValue={toDateInput(promotion.endsAt)}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">Leave blank for no end date.</p>
</div>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Save changes
</button>
</form>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
export async function createPromotion(formData: FormData) {
const message = String(formData.get('message') ?? '').trim();
const discountPercent = Math.round(Number(formData.get('discountPercent') ?? 0));
const scope = String(formData.get('scope') ?? 'ALL') === 'SPECIFIC' ? 'SPECIFIC' : 'ALL';
const productIds = formData.getAll('productIds').map(String).filter(Boolean);
const startInput = String(formData.get('startsAt') ?? '').trim();
const endInput = String(formData.get('endsAt') ?? '').trim();
if (!message) throw new Error('A banner message is required.');
if (!discountPercent || discountPercent < 1 || discountPercent > 100) {
throw new Error('Discount must be between 1 and 100 percent.');
}
if (scope === 'SPECIFIC' && productIds.length === 0) {
throw new Error('Pick at least one product, or choose "All items" instead.');
}
await prisma.promotion.create({
data: {
message,
discountPercent,
scope,
startsAt: startInput ? new Date(startInput) : null,
endsAt: endInput ? new Date(`${endInput}T23:59:59`) : null,
products: scope === 'SPECIFIC' ? { connect: productIds.map((id) => ({ id })) } : undefined,
},
});
redirect('/admin/promotions');
}
export async function updatePromotion(formData: FormData) {
const id = String(formData.get('id') ?? '');
const message = String(formData.get('message') ?? '').trim();
const discountPercent = Math.round(Number(formData.get('discountPercent') ?? 0));
const scope = String(formData.get('scope') ?? 'ALL') === 'SPECIFIC' ? 'SPECIFIC' : 'ALL';
const productIds = formData.getAll('productIds').map(String).filter(Boolean);
const startInput = String(formData.get('startsAt') ?? '').trim();
const endInput = String(formData.get('endsAt') ?? '').trim();
if (!id) throw new Error('Missing promotion id.');
if (!message) throw new Error('A banner message is required.');
if (!discountPercent || discountPercent < 1 || discountPercent > 100) {
throw new Error('Discount must be between 1 and 100 percent.');
}
if (scope === 'SPECIFIC' && productIds.length === 0) {
throw new Error('Pick at least one product, or choose "All items" instead.');
}
await prisma.promotion.update({
where: { id },
data: {
message,
discountPercent,
scope,
startsAt: startInput ? new Date(startInput) : null,
endsAt: endInput ? new Date(`${endInput}T23:59:59`) : null,
// `set` replaces the full connection list in one go — correctly handles
// switching scope (SPECIFIC -> ALL clears it) and adding/removing products.
products: { set: scope === 'SPECIFIC' ? productIds.map((pid) => ({ id: pid })) : [] },
},
});
redirect('/admin/promotions');
}
export async function deletePromotion(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
await prisma.promotion.delete({ where: { id } });
redirect('/admin/promotions');
}
+84
View File
@@ -0,0 +1,84 @@
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import PromotionScopePicker from '@/components/PromotionScopePicker';
import { createPromotion } from '../actions';
export default async function NewPromotionPage() {
const products = await prisma.product.findMany({ orderBy: { name: 'asc' } });
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Add a promotion</h1>
<p className="mt-2 text-sm text-muted">
Shown as a banner at the top of every page while its date window is active, and actually
discounts the price of whatever it covers every item, or specific ones you pick.
</p>
<form action={createPromotion} className="mt-10 space-y-6">
<div>
<label htmlFor="message" className="tag-label mb-2 block">
Banner message
</label>
<textarea
id="message"
name="message"
required
rows={2}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="20% off everything this weekend!"
/>
</div>
<div>
<label htmlFor="discountPercent" className="tag-label mb-2 block">
Discount (%)
</label>
<input
id="discountPercent"
name="discountPercent"
type="number"
min={1}
max={100}
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="20"
/>
</div>
<PromotionScopePicker products={products} />
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="startsAt" className="tag-label mb-2 block">
Starts (optional)
</label>
<input
id="startsAt"
name="startsAt"
type="date"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">Leave blank to start immediately.</p>
</div>
<div>
<label htmlFor="endsAt" className="tag-label mb-2 block">
Ends (optional)
</label>
<input
id="endsAt"
name="endsAt"
type="date"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">Leave blank for no end date.</p>
</div>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Add promotion
</button>
</form>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { deletePromotion } from './actions';
function statusFor(promotion: { startsAt: Date | null; endsAt: Date | null }, now: Date) {
if (promotion.startsAt && promotion.startsAt > now) return { label: 'Scheduled', className: 'bg-splash-orange/10 text-splash-orange' };
if (promotion.endsAt && promotion.endsAt < now) return { label: 'Ended', className: 'bg-muted/10 text-muted' };
return { label: 'Active now', className: 'bg-splash-blue/10 text-splash-blue' };
}
function formatDate(d: Date | null) {
if (!d) return null;
return d.toLocaleDateString(undefined, { dateStyle: 'medium' });
}
export default async function PromotionsPage() {
const promotions = await prisma.promotion.findMany({
orderBy: { createdAt: 'desc' },
include: { products: { select: { id: true } } },
});
const now = new Date();
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">Promotions</h1>
<Link
href="/admin/promotions/new"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add promotion
</Link>
</div>
<p className="mt-2 text-sm text-muted">
A banner shown at the top of every page while its date window is active. If more than one
is active at once, the most recently created one shows.
</p>
{promotions.length === 0 ? (
<p className="mt-8 text-sm text-muted">No promotions yet.</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{promotions.map((p) => {
const status = statusFor(p, now);
return (
<div key={p.id} className="flex items-center gap-4 py-4">
<div className="flex-1">
<p className="font-medium">{p.message}</p>
<p className="tag-label mt-0.5">
{p.discountPercent}% off {p.scope === 'ALL' ? 'All items' : `${p.products.length} item${p.products.length === 1 ? '' : 's'}`}
{' · '}
{formatDate(p.startsAt) ?? 'Starts immediately'} {formatDate(p.endsAt) ?? 'no end date'}
</p>
</div>
<span className={`tag-label rounded-full px-3 py-1 ${status.className}`}>{status.label}</span>
<Link href={`/admin/promotions/${p.id}/edit`} className="tag-label hover:text-clay">
Edit
</Link>
<form action={deletePromotion}>
<input type="hidden" name="id" value={p.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
</div>
);
})}
</div>
)}
</div>
);
}