Initial commit: Craft2Prints with Stages 1-3
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user