- Add ShippingRate model to Prisma schema with service name, base price, per-kg surcharge, estimated delivery days, and international flag - Create migration to add shipping_rates table - Update seed.ts to populate initial Royal Mail rates (domestic and international) - Refactor getRoyalMailShippingQuotes to fetch rates from database with fallback to defaults - Add /admin/shipping-rates page for managing rates - Add ShippingRatesForm component with inline editing and add/remove functionality - Add server actions for saving and deleting rates - Add "Shipping rates" link to admin navigation Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
215 lines
5.7 KiB
TypeScript
215 lines
5.7 KiB
TypeScript
import { PrismaClient } from '@prisma/client';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
const categories = [
|
|
{ name: 'Apparel', slug: 'APPAREL' },
|
|
{ name: 'Drinkware', slug: 'DRINKWARE' },
|
|
{ name: 'Phone case', slug: 'PHONE_CASE' },
|
|
];
|
|
|
|
const products: Array<{
|
|
slug: string;
|
|
name: string;
|
|
category: string;
|
|
description: string;
|
|
basePrice: number;
|
|
colors: string[];
|
|
sizes: string[];
|
|
mockup: string;
|
|
printArea: { xPct: number; yPct: number; wPct: number; hPct: number };
|
|
imageUrl: string | null;
|
|
imageUrlBack: string | null;
|
|
photoRecolorable: boolean;
|
|
}> = [
|
|
{
|
|
slug: 'classic-tee',
|
|
name: 'Classic Tee',
|
|
category: 'APPAREL',
|
|
description:
|
|
'Heavyweight 220gsm combed cotton. Your artwork, printed dead-center on the chest.',
|
|
basePrice: 2900,
|
|
colors: ['#FFFFFF', '#17181C', '#2B4C3F', '#8A8578', '#1EA7E0', '#E5227E', '#F5871F', '#5B2C86', '#7A1F1F', '#D8D2C2'],
|
|
sizes: ['S', 'M', 'L', 'XL', 'XXL'],
|
|
mockup: 'tshirt',
|
|
printArea: { xPct: 0.32, yPct: 0.27, wPct: 0.36, hPct: 0.34 },
|
|
imageUrl: '/seed/tee-front.png',
|
|
imageUrlBack: '/seed/tee-back.png',
|
|
photoRecolorable: true,
|
|
},
|
|
{
|
|
slug: 'heavyweight-hoodie',
|
|
name: 'Heavyweight Hoodie',
|
|
category: 'APPAREL',
|
|
description: 'Brushed-fleece interior, boxy fit. Built for a bold front print.',
|
|
basePrice: 5400,
|
|
colors: ['#17181C', '#8A8578', '#2B4C3F', '#7A1F1F', '#1EA7E0', '#D8D2C2', '#5B2C86'],
|
|
sizes: ['S', 'M', 'L', 'XL', 'XXL'],
|
|
mockup: 'hoodie',
|
|
printArea: { xPct: 0.33, yPct: 0.34, wPct: 0.34, hPct: 0.28 },
|
|
imageUrl: null,
|
|
imageUrlBack: null,
|
|
photoRecolorable: false,
|
|
},
|
|
{
|
|
slug: 'ceramic-mug',
|
|
name: 'Ceramic Mug',
|
|
category: 'DRINKWARE',
|
|
description: '11oz glossy ceramic. Dishwasher-safe, wraparound print.',
|
|
basePrice: 1800,
|
|
colors: ['#FFFFFF', '#17181C', '#1EA7E0', '#E5227E', '#F5871F', '#5B2C86'],
|
|
sizes: [],
|
|
mockup: 'mug',
|
|
printArea: { xPct: 0.22, yPct: 0.32, wPct: 0.56, hPct: 0.28 },
|
|
imageUrl: null,
|
|
imageUrlBack: null,
|
|
photoRecolorable: false,
|
|
},
|
|
{
|
|
slug: 'travel-tumbler',
|
|
name: 'Travel Tumbler',
|
|
category: 'DRINKWARE',
|
|
description: '16oz double-wall stainless steel, keeps drinks cold for 24 hours.',
|
|
basePrice: 3200,
|
|
colors: ['#17181C', '#8A8578', '#C9A227', '#1EA7E0', '#E5227E', '#2B4C3F'],
|
|
sizes: [],
|
|
mockup: 'tumbler',
|
|
printArea: { xPct: 0.26, yPct: 0.28, wPct: 0.48, hPct: 0.36 },
|
|
imageUrl: null,
|
|
imageUrlBack: null,
|
|
photoRecolorable: false,
|
|
},
|
|
{
|
|
slug: 'snap-phone-case',
|
|
name: 'Snap Phone Case',
|
|
category: 'PHONE_CASE',
|
|
description: 'Slim polycarbonate snap case with a matte finish.',
|
|
basePrice: 2200,
|
|
colors: ['#FFFFFF', '#17181C', '#2B4C3F', '#E5227E', '#1EA7E0', '#F5871F'],
|
|
sizes: [],
|
|
mockup: 'phonecase',
|
|
printArea: { xPct: 0.28, yPct: 0.16, wPct: 0.44, hPct: 0.5 },
|
|
imageUrl: null,
|
|
imageUrlBack: null,
|
|
photoRecolorable: false,
|
|
},
|
|
{
|
|
slug: 'tough-phone-case',
|
|
name: 'Tough Phone Case',
|
|
category: 'PHONE_CASE',
|
|
description: 'Dual-layer shock-resistant case with raised edges.',
|
|
basePrice: 2900,
|
|
colors: ['#17181C', '#8A8578', '#5B2C86', '#7A1F1F'],
|
|
sizes: [],
|
|
mockup: 'phonecase',
|
|
printArea: { xPct: 0.3, yPct: 0.18, wPct: 0.4, hPct: 0.46 },
|
|
imageUrl: null,
|
|
imageUrlBack: null,
|
|
photoRecolorable: false,
|
|
},
|
|
{
|
|
slug: 'shaker-bottle',
|
|
name: 'Shaker Bottle',
|
|
category: 'DRINKWARE',
|
|
description: '20oz insulated shaker bottle with a secure flip-top lid. Your artwork, printed on the body.',
|
|
basePrice: 1500,
|
|
colors: ['#FFFFFF'],
|
|
sizes: [],
|
|
mockup: 'tumbler',
|
|
printArea: { xPct: 0.32, yPct: 0.36, wPct: 0.36, hPct: 0.38 },
|
|
imageUrl: '/seed/shaker-bottle.png',
|
|
imageUrlBack: null,
|
|
photoRecolorable: false,
|
|
},
|
|
];
|
|
|
|
const shippingRates = [
|
|
{
|
|
service: 'Special Delivery Guaranteed by 9am',
|
|
basePrice: 895,
|
|
pricePerKg: 0,
|
|
estimatedDays: 1,
|
|
isInternational: false,
|
|
},
|
|
{
|
|
service: 'Royal Mail 24®',
|
|
basePrice: 495,
|
|
pricePerKg: 0,
|
|
estimatedDays: 1,
|
|
isInternational: false,
|
|
},
|
|
{
|
|
service: 'Royal Mail 48®',
|
|
basePrice: 295,
|
|
pricePerKg: 0,
|
|
estimatedDays: 3,
|
|
isInternational: false,
|
|
},
|
|
{
|
|
service: 'International Signed',
|
|
basePrice: 1495,
|
|
pricePerKg: 300,
|
|
estimatedDays: 14,
|
|
isInternational: true,
|
|
},
|
|
{
|
|
service: 'International Economy',
|
|
basePrice: 1095,
|
|
pricePerKg: 200,
|
|
estimatedDays: 21,
|
|
isInternational: true,
|
|
},
|
|
];
|
|
|
|
async function main() {
|
|
for (const c of categories) {
|
|
await prisma.category.upsert({
|
|
where: { slug: c.slug },
|
|
update: {},
|
|
create: c,
|
|
});
|
|
}
|
|
|
|
for (const p of products) {
|
|
const data = {
|
|
name: p.name,
|
|
category: p.category,
|
|
description: p.description,
|
|
basePrice: p.basePrice,
|
|
colors: JSON.stringify(p.colors),
|
|
sizes: p.sizes.length > 0 ? JSON.stringify(p.sizes) : null,
|
|
mockup: p.mockup,
|
|
printArea: JSON.stringify(p.printArea),
|
|
imageUrl: p.imageUrl,
|
|
imageUrlBack: p.imageUrlBack,
|
|
photoRecolorable: p.photoRecolorable,
|
|
};
|
|
await prisma.product.upsert({
|
|
where: { slug: p.slug },
|
|
update: data, // keeps these 6 starter products in sync when the seed file changes
|
|
create: { slug: p.slug, ...data },
|
|
});
|
|
}
|
|
|
|
for (const rate of shippingRates) {
|
|
await prisma.shippingRate.upsert({
|
|
where: { service: rate.service },
|
|
update: { ...rate },
|
|
create: { ...rate },
|
|
});
|
|
}
|
|
|
|
console.log(
|
|
`Seeded ${categories.length} categories, ${products.length} products, and ${shippingRates.length} shipping rates.`
|
|
);
|
|
}
|
|
|
|
main()
|
|
.catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
});
|