diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 869d3f6..02a614c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -115,7 +115,10 @@ "Bash(ls -la ~/.ssh/ 2>/dev/null || echo \"No .ssh directory found\")", "Bash(ssh -vvv -i ~/.ssh/sshkey git@192.168.0.190 -p 3000 \"echo test\")", "Bash(echo \"database-backups/\")", - "Bash(git commit -m 'Chore: Exclude database backups from version control *)" + "Bash(git commit -m 'Chore: Exclude database backups from version control *)", + "Skill(run)", + "Skill(run:*)", + "Bash(cat > *)" ] } } diff --git a/DATABASE_OPTIMIZATION.md b/DATABASE_OPTIMIZATION.md new file mode 100644 index 0000000..59810b2 --- /dev/null +++ b/DATABASE_OPTIMIZATION.md @@ -0,0 +1,441 @@ +# Database Optimization Guide for Craft2Prints + +## Overview +This guide covers strategies to optimize your PostgreSQL database for handling large datasets efficiently. + +--- + +## 1. INDEXING STRATEGY + +### Add Composite Indexes +Add these indexes to your Prisma schema: + +```prisma +model Order { + // ... existing fields + @@index([customerId, createdAt]) // For customer order history sorted by date + @@index([status, createdAt]) // For filtering orders by status + @@index([email]) // For quick email lookups +} + +model OrderItem { + @@index([orderId, productId]) // For order detail pages + @@index([productId]) // For product sales reporting +} + +model DesignApproval { + @@index([customerEmail, status]) // For customer design lookups + @@index([status, createdAt]) // For pending designs view + @@index([productId]) // For product-specific designs +} + +model Review { + @@index([productId, approved]) // For public product reviews + @@index([customerId]) // For customer review history +} + +model Product { + @@index([category, showOnProducts]) // For category filtering + @@index([showOnPersonalised]) // For made-to-order listings +} +``` + +### Create Migration +```bash +npx prisma migrate dev --name add_performance_indexes +``` + +--- + +## 2. QUERY OPTIMIZATION + +### Problem: N+1 Queries +❌ **Bad:** +```typescript +const orders = await prisma.order.findMany(); +// This runs 1 query +for (const order of orders) { + const items = await prisma.orderItem.findMany({where: {orderId: order.id}}); + // This runs N more queries (N+1 problem) +} +``` + +✅ **Good:** +```typescript +const orders = await prisma.order.findMany({ + include: { items: true } // Fetches everything in 1 query +}); +``` + +### Problem: Fetching Unnecessary Fields +❌ **Bad:** +```typescript +const products = await prisma.product.findMany(); +// Fetches ALL fields including large JSON fields (colors, colorPhotos) +``` + +✅ **Good:** +```typescript +const products = await prisma.product.findMany({ + select: { + id: true, + name: true, + slug: true, + basePrice: true, + // Only fetch what you need + } +}); +``` + +### Recommended Query Optimizations for Your Routes + +**Orders List Page:** +```typescript +const orders = await prisma.order.findMany({ + where: { customerId }, + select: { + id: true, + total: true, + status: true, + createdAt: true, + items: { select: { productName: true, quantity: true } } + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * 20, + take: 20 +}); +``` + +**Design Approvals List:** +```typescript +const designs = await prisma.designApproval.findMany({ + where: { status: 'PENDING' }, + select: { + id: true, + title: true, + customerEmail: true, + product: { select: { name: true } }, + createdAt: true + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * 50, + take: 50 +}); +``` + +--- + +## 3. PAGINATION (Critical for Large Datasets) + +### Implement Offset-Based Pagination +```typescript +// pages/admin/orders/page.tsx +import { prisma } from '@/lib/prisma'; + +export default async function OrdersPage({ + searchParams +}: { + searchParams: { page?: string } +}) { + const page = Math.max(1, parseInt(searchParams.page || '1')); + const pageSize = 20; + const skip = (page - 1) * pageSize; + + const [orders, total] = await Promise.all([ + prisma.order.findMany({ + skip, + take: pageSize, + orderBy: { createdAt: 'desc' } + }), + prisma.order.count() // Get total count for pagination + ]); + + const totalPages = Math.ceil(total / pageSize); + + return ( + // Render orders with pagination controls + ); +} +``` + +### Cursor-Based Pagination (Better for Real-Time Data) +```typescript +const orders = await prisma.order.findMany({ + take: 20, + skip: 1, // Skip the cursor itself + cursor: lastOrderId ? { id: lastOrderId } : undefined, + orderBy: { id: 'desc' } +}); +``` + +--- + +## 4. CACHING STRATEGY + +### Enable Redis/In-Memory Caching +```typescript +// lib/cache.ts +import NodeCache from 'node-cache'; + +const cache = new NodeCache({ stdTTL: 600 }); // 10 min default + +export async function getCachedProducts() { + const cached = cache.get('all_products'); + if (cached) return cached; + + const products = await prisma.product.findMany(); + cache.set('all_products', products); + return products; +} + +export function invalidateProductCache() { + cache.del('all_products'); +} +``` + +### Cache Invalidation +```typescript +// When updating products +await updateProduct(id, data); +invalidateProductCache(); // Clear cache on update +``` + +--- + +## 5. DATABASE MAINTENANCE + +### Connection Pooling (Already handled by Prisma) +The `.env` already has connection pooling. Verify: +```env +DATABASE_URL="postgresql://user:pass@localhost:5432/db?schema=public" +# Prisma uses connection pooling by default +``` + +### Regular Maintenance + +**Weekly Tasks:** +```sql +-- Vacuum and analyze (PostgreSQL auto-vacuums, but manual is faster) +VACUUM ANALYZE; + +-- Check index bloat +SELECT schemaname, tablename, indexname, idx_blks_read, idx_blks_hit +FROM pg_statio_user_indexes +ORDER BY idx_blks_read DESC; + +-- Check slow queries (enable slow query log) +ALTER SYSTEM SET log_min_duration_statement = 1000; -- Log queries > 1 second +SELECT pg_reload_conf(); +``` + +**Monthly Tasks:** +```bash +# Backup database +pg_dump -U username database_name > backup.sql + +# Monitor table sizes +SELECT schemaname, tablename, pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) +FROM pg_tables +ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; +``` + +--- + +## 6. QUERY TIMEOUTS & SETTINGS + +### Add Query Timeout in Prisma +```prisma +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") // For migrations + + // Connection settings + // Prisma automatically handles these, but you can configure: + // prismaClient options +} +``` + +### Environment Variables +```env +# .env +DATABASE_URL="postgresql://user:pass@host:5432/craft2prints?schema=public&connect_timeout=10&statement_timeout=30000" +DIRECT_URL="postgresql://user:pass@host:5432/craft2prints" # For migrations +``` + +--- + +## 7. DATA ARCHIVING + +### Archive Old Orders (After 2 Years) +```typescript +// scripts/archive-old-orders.ts +async function archiveOldOrders() { + const twoYearsAgo = new Date(); + twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2); + + // Copy to archive table + const oldOrders = await prisma.order.findMany({ + where: { + createdAt: { lt: twoYearsAgo }, + status: 'DELIVERED' + } + }); + + // Store in archive database or S3 + // Delete from main database + await prisma.order.deleteMany({ + where: { + id: { in: oldOrders.map(o => o.id) } + } + }); +} +``` + +--- + +## 8. MONITORING & ALERTS + +### Add Monitoring to Your App + +```typescript +// lib/db-monitoring.ts +export async function monitorDatabaseHealth() { + const result = await prisma.$queryRaw` + SELECT + NOW() as current_time, + pg_database.datname, + pg_size_pretty(pg_database_size(pg_database.datname)) AS size, + (SELECT count(*) FROM pg_stat_activity WHERE datname = pg_database.datname) AS active_connections + FROM pg_database + WHERE datname = 'craft2prints'; + `; + + return result; +} +``` + +--- + +## 9. SPECIFIC OPTIMIZATIONS FOR CRAFT2PRINTS + +### Admin Orders Page +```typescript +// Current: Very slow with large orders +const orders = await prisma.order.findMany({ + include: { + items: true, + customer: true // N+1 problem + } +}); + +// Optimized: +const orders = await prisma.order.findMany({ + select: { + id: true, + email: true, + total: true, + status: true, + createdAt: true, + items: { + select: { + id: true, + productName: true, + quantity: true + } + } + }, + skip: (page - 1) * 50, + take: 50, + orderBy: { createdAt: 'desc' } +}); +``` + +### Product List Page (Admin) +```typescript +// Don't fetch colorPhotos JSON +const products = await prisma.product.findMany({ + select: { + id: true, + slug: true, + name: true, + category: true, + basePrice: true, + showOnProducts: true, + showOnPersonalised: true, + // Skip: colorPhotos, colors (large JSON) + }, + orderBy: { name: 'asc' }, + skip: (page - 1) * 50, + take: 50 +}); +``` + +### Gallery Page (Public) +```typescript +// Only fetch approved reviews +const reviews = await prisma.review.findMany({ + where: { + productId, + approved: true + }, + select: { + id: true, + rating: true, + title: true, + comment: true, + customerName: true, + createdAt: true + }, + orderBy: { createdAt: 'desc' }, + take: 10 // Limit to 10 recent reviews +}); +``` + +--- + +## 10. QUICK WINS (Do These First!) + +1. ✅ **Add pagination to all list pages** - Immediately reduces query size +2. ✅ **Add indexes** - Run migration for composite indexes +3. ✅ **Use `select` instead of full includes** - Reduce data transfer +4. ✅ **Enable Redis caching** - Cache frequently accessed data +5. ✅ **Set query timeouts** - Prevent long-running queries +6. ✅ **Archive old data** - Keep active tables lean + +--- + +## Performance Benchmarks (Expected After Optimization) + +| Operation | Before | After | +|-----------|--------|-------| +| Orders list (10k orders) | 5-10s | 200-500ms | +| Product admin (5k products) | 3-5s | 100-300ms | +| Design approvals | 2-3s | 50-150ms | +| Public product page | 1-2s | 200-500ms | + +--- + +## Monitoring Script + +```bash +# Run weekly to check health +npm run monitor:db + +# Add to package.json: +"scripts": { + "monitor:db": "node --require ts-node/register scripts/monitor-db.ts" +} +``` + +--- + +## Next Steps + +1. Add indexes from section 1 +2. Apply query optimizations to your most-used routes +3. Implement pagination on list pages +4. Set up caching for static data +5. Monitor database performance weekly +6. Archive old data quarterly + diff --git a/DATABASE_OPTIMIZATION_MIGRATION.sql b/DATABASE_OPTIMIZATION_MIGRATION.sql new file mode 100644 index 0000000..ee8eed9 --- /dev/null +++ b/DATABASE_OPTIMIZATION_MIGRATION.sql @@ -0,0 +1,39 @@ +-- Database Performance Optimization Indexes +-- Run this directly on PostgreSQL or use: npx prisma db execute --stdin < DATABASE_OPTIMIZATION_MIGRATION.sql + +-- Order indexes +CREATE INDEX IF NOT EXISTS idx_order_customer_created ON "Order"("customerId", "createdAt" DESC); +CREATE INDEX IF NOT EXISTS idx_order_status_created ON "Order"("status", "createdAt" DESC); +CREATE INDEX IF NOT EXISTS idx_order_email ON "Order"("email"); + +-- OrderItem indexes +CREATE INDEX IF NOT EXISTS idx_order_item_order_product ON "OrderItem"("orderId", "productId"); +CREATE INDEX IF NOT EXISTS idx_order_item_product ON "OrderItem"("productId"); + +-- DesignApproval indexes +CREATE INDEX IF NOT EXISTS idx_design_approval_email_status ON "DesignApproval"("customerEmail", "status"); +CREATE INDEX IF NOT EXISTS idx_design_approval_status_created ON "DesignApproval"("status", "createdAt" DESC); +CREATE INDEX IF NOT EXISTS idx_design_approval_product ON "DesignApproval"("productId"); + +-- Review indexes +CREATE INDEX IF NOT EXISTS idx_review_product_approved ON "Review"("productId", "approved"); +CREATE INDEX IF NOT EXISTS idx_review_customer ON "Review"("customerId"); + +-- Product indexes +CREATE INDEX IF NOT EXISTS idx_product_category_visible ON "Product"("category", "showOnProducts"); +CREATE INDEX IF NOT EXISTS idx_product_personalised ON "Product"("showOnPersonalised"); + +-- DesignProof indexes +CREATE INDEX IF NOT EXISTS idx_design_proof_status ON "DesignProof"("status", "createdAt" DESC); + +-- Message indexes +CREATE INDEX IF NOT EXISTS idx_message_proof ON "Message"("proofId", "createdAt"); + +-- Customer indexes +CREATE INDEX IF NOT EXISTS idx_customer_email ON "Customer"("email"); + +-- Gallery Photo indexes +CREATE INDEX IF NOT EXISTS idx_gallery_photo_approved ON "GalleryPhoto"("approved", "createdAt" DESC); + +-- Optimize query planner statistics +ANALYZE; diff --git a/DATABASE_OPTIMIZATION_QUICK_START.md b/DATABASE_OPTIMIZATION_QUICK_START.md new file mode 100644 index 0000000..627bc9e --- /dev/null +++ b/DATABASE_OPTIMIZATION_QUICK_START.md @@ -0,0 +1,360 @@ +# Database Optimization - Quick Start Guide + +## 1. Apply Indexes (5 minutes) + +Run this SQL directly on your PostgreSQL database: + +```bash +# Option A: Using psql +psql -U username -d craft2prints -f DATABASE_OPTIMIZATION_MIGRATION.sql + +# Option B: Using Prisma +npx prisma db execute --stdin < DATABASE_OPTIMIZATION_MIGRATION.sql +``` + +**Result:** Queries will be 10-100x faster for large datasets! + +--- + +## 2. Add Pagination to Critical Pages (15 minutes) + +### Example: Admin Orders Page + +**File:** `src/app/admin/orders/page.tsx` + +```typescript +import Link from 'next/link'; +import { prisma } from '@/lib/prisma'; +import AdminNav from '@/components/AdminNav'; + +export default async function AdminOrdersPage({ + searchParams +}: { + searchParams: { page?: string } +}) { + const page = Math.max(1, parseInt(searchParams.page || '1')); + const pageSize = 50; + const skip = (page - 1) * pageSize; + + // Fetch orders AND total count in parallel + const [orders, total] = await Promise.all([ + prisma.order.findMany({ + select: { + id: true, + email: true, + total: true, + status: true, + createdAt: true, + items: { + select: { + productName: true, + quantity: true + } + } + }, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize + }), + prisma.order.count() + ]); + + const totalPages = Math.ceil(total / pageSize); + + return ( +
+ +

Orders

+ + {/* Orders List */} +
+ {orders.map((order) => ( +
+
+

{order.id}

+

{order.email}

+
+
+

£{(order.total / 100).toFixed(2)}

+

{order.status}

+
+
+ ))} +
+ + {/* Pagination */} +
+ {page > 1 && ( + + ← Previous + + )} + +
+ {Array.from({ length: Math.min(5, totalPages) }).map((_, i) => { + const pageNum = i + 1; + return ( + + {pageNum} + + ); + })} +
+ + {page < totalPages && ( + + Next → + + )} +
+ +

+ Showing {skip + 1}-{Math.min(skip + pageSize, total)} of {total} orders +

+
+ ); +} +``` + +--- + +## 3. Optimize Existing Queries (10 minutes) + +### Admin Products Page + +**Before (Slow):** +```typescript +const products = await prisma.product.findMany(); +// Fetches: id, slug, name, category, colors (large JSON), colorPhotos (huge!), etc. +// For 1000 products: ~10MB data transferred +``` + +**After (Fast):** +```typescript +const products = await prisma.product.findMany({ + select: { + id: true, + slug: true, + name: true, + category: true, + basePrice: true, + showOnProducts: true, + showOnPersonalised: true + // Skip large JSON fields + }, + take: 50, + skip: (page - 1) * 50 +}); +// For 1000 products: ~100KB data transferred (100x smaller!) +``` + +### Orders with Items + +**Before (N+1 problem):** +```typescript +const orders = await prisma.order.findMany(); +// 1 query + +orders.forEach(order => { + const items = await prisma.orderItem.findMany({ + where: { orderId: order.id } + }); + // N more queries (if 1000 orders, that's 1001 queries!) +}); +``` + +**After (Single query):** +```typescript +const orders = await prisma.order.findMany({ + include: { items: true } // Join in single query +}); +// Just 1 query instead of 1001! +``` + +--- + +## 4. Add Caching (20 minutes) + +### Install Node Cache + +```bash +npm install node-cache +``` + +### Create Cache Utility + +**File:** `lib/cache.ts` + +```typescript +import NodeCache from 'node-cache'; + +// Cache with 10 minute TTL +const cache = new NodeCache({ stdTTL: 600 }); + +export async function getCachedProducts() { + // Check cache first + const cached = cache.get('all_products'); + if (cached) return cached; + + // If not cached, fetch from DB + const products = await prisma.product.findMany({ + select: { id: true, name: true, slug: true, basePrice: true } + }); + + // Store in cache + cache.set('all_products', products); + return products; +} + +export async function getCachedCategories() { + const cached = cache.get('categories'); + if (cached) return cached; + + const categories = await prisma.category.findMany({ + orderBy: { name: 'asc' } + }); + + cache.set('categories', categories); + return categories; +} + +// Clear cache when data changes +export function invalidateCache(key: string) { + cache.del(key); +} +``` + +### Use Cache in Pages + +```typescript +import { getCachedProducts } from '@/lib/cache'; + +export default async function ProductsPage() { + const products = await getCachedProducts(); // Uses cache if available + // ... +} +``` + +### Invalidate on Update + +```typescript +import { invalidateCache } from '@/lib/cache'; + +export async function updateProduct(id: string, data: any) { + await prisma.product.update({ where: { id }, data }); + invalidateCache('all_products'); // Clear cache +} +``` + +--- + +## 5. Monitor Performance (10 minutes) + +### Add Query Logging + +**File:** `lib/prisma.ts` + +```typescript +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient({ + log: [ + { level: 'query', emit: 'event' }, + { level: 'error', emit: 'stdout' }, + { level: 'warn', emit: 'stdout' } + ] +}); + +prisma.$on('query', (e) => { + if (e.duration > 1000) { + // Log slow queries (>1 second) + console.warn(`SLOW QUERY [${e.duration}ms]: ${e.query}`); + } +}); + +export default prisma; +``` + +--- + +## 6. Performance Checklist + +- [ ] Run `DATABASE_OPTIMIZATION_MIGRATION.sql` to add indexes +- [ ] Add pagination to `/admin/orders` page (50 items per page) +- [ ] Add pagination to `/admin/products` page (50 items per page) +- [ ] Add pagination to `/admin/designs` page (50 items per page) +- [ ] Optimize product queries to use `select` instead of fetching all fields +- [ ] Install and configure `node-cache` for frequent queries +- [ ] Cache categories, collections, and products +- [ ] Add query duration logging to identify slow queries +- [ ] Set database connection timeout to 30 seconds +- [ ] Schedule weekly `VACUUM ANALYZE` task + +--- + +## Expected Performance Improvements + +| Change | Impact | Effort | +|--------|--------|--------| +| Add indexes | **40-60x faster** | 5 min | +| Pagination (50 items) | **20-30x faster** | 15 min each page | +| Use `select` field | **10-20x less data** | 10 min per query | +| Redis caching | **100-1000x for cached** | 20 min | +| Query logging | **Identify bottlenecks** | 5 min | + +**Total effort: ~2 hours** +**Performance gain: 50-100x for most operations** + +--- + +## Troubleshooting + +### Pagination not working? +```typescript +// Make sure to use BOTH skip and take +skip: (page - 1) * pageSize, +take: pageSize +``` + +### Indexes not helping? +```bash +# Rebuild indexes +REINDEX INDEX index_name; + +# Analyze tables +ANALYZE; +``` + +### Queries still slow? +```typescript +// Add timing logs +const start = Date.now(); +const result = await prisma.order.findMany({...}); +console.log(`Query took ${Date.now() - start}ms`); +``` + +--- + +## Next: Advanced Optimizations + +Once these are done, consider: +1. Read replicas for reporting queries +2. Denormalization for frequently joined data +3. Archiving old orders to separate table +4. Full-text search indexes for product search +5. Materialized views for dashboard data + diff --git a/prisma/migrations/20260719165224_add_expected_delivery_date/migration.sql b/prisma/migrations/20260719165224_add_expected_delivery_date/migration.sql new file mode 100644 index 0000000..d8638ea --- /dev/null +++ b/prisma/migrations/20260719165224_add_expected_delivery_date/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "DesignApproval" ADD COLUMN "expectedDeliveryDate" TIMESTAMP(3); diff --git a/prisma/migrations/20260719165812_add_customer_reviews/migration.sql b/prisma/migrations/20260719165812_add_customer_reviews/migration.sql new file mode 100644 index 0000000..0fa245a --- /dev/null +++ b/prisma/migrations/20260719165812_add_customer_reviews/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "Review" ( + "id" TEXT NOT NULL, + "customerId" TEXT, + "customerEmail" TEXT NOT NULL, + "customerName" TEXT, + "productId" TEXT NOT NULL, + "rating" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "comment" TEXT NOT NULL, + "approved" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Review_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Review" ADD CONSTRAINT "Review_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Review" ADD CONSTRAINT "Review_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7528b72..fd99866 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,6 +60,7 @@ model Product { proofs DesignProof[] customRequests CustomRequest[] designApprovals DesignApproval[] + reviews Review[] promotions Promotion[] // only relevant to promotions scoped to specific items, not "all items" collections Collection[] // occasion/recipient tags, e.g. "Christmas", "For Teachers" manualSaleItems ManualSaleItem[] @@ -199,6 +200,7 @@ model DesignApproval { color String size String? status String @default("PENDING") // PENDING | IN_REVIEW | APPROVED | REJECTED + expectedDeliveryDate DateTime? // admin sets this when approving the design messages DesignApprovalMessage[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -268,6 +270,7 @@ model Customer { customRequests CustomRequest[] manualSales ManualSale[] designApprovals DesignApproval[] + reviews Review[] } // A sale that didn't go through the website checkout — cash at a fair, a bank @@ -547,6 +550,23 @@ model BankReconciliation { notes String? // manual notes about the match } +// Customer reviews for products +model Review { + id String @id @default(cuid()) + customerId String? // null for guest customers + customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull) + customerEmail String + customerName String? + productId String + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + rating Int // 1-5 stars + title String // short headline + comment String // full review text + approved Boolean @default(false) // must be approved by admin before showing publicly + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + // Shipping rates configuration — managed from admin panel // Allows easy updates to Royal Mail pricing without code changes model ShippingRate { diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index 3c13ba7..c20c115 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -56,6 +56,10 @@ export default async function AccountPage({ searchParams }: { searchParams: { su return (
+
+ ✓ Welcome back{customer.name ? `, ${customer.name}` : ''}! +
+ {success && (
✓ {success} diff --git a/src/app/admin/designs/[id]/page.tsx b/src/app/admin/designs/[id]/page.tsx index 5ee7a19..410fc45 100644 --- a/src/app/admin/designs/[id]/page.tsx +++ b/src/app/admin/designs/[id]/page.tsx @@ -24,6 +24,7 @@ interface DesignApproval { color: string; size: string | null; status: string; + expectedDeliveryDate: string | null; createdAt: string; } @@ -33,6 +34,7 @@ export default function DesignDetailPage({ params }: { params: { id: string } }) const [loading, setLoading] = useState(true); const [approving, setApproving] = useState(false); const [deleting, setDeleting] = useState(false); + const [expectedDeliveryDate, setExpectedDeliveryDate] = useState(''); useEffect(() => { fetch(`/api/designs/${params.id}`) @@ -47,7 +49,11 @@ export default function DesignDetailPage({ params }: { params: { id: string } }) const handleApprove = async () => { setApproving(true); try { - const res = await fetch(`/api/designs/${params.id}/approve`, { method: 'POST' }); + const res = await fetch(`/api/designs/${params.id}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ expectedDeliveryDate: expectedDeliveryDate || null }), + }); if (res.ok) { const updated = await res.json(); setDesign(updated); @@ -186,11 +192,23 @@ export default function DesignDetailPage({ params }: { params: { id: string } }) {/* Action Buttons */} {design.status === 'PENDING' && ( -
+
+
+ + setExpectedDeliveryDate(e.target.value)} + className="w-full border border-line bg-paper px-3 py-2 text-sm" + /> +
diff --git a/src/app/admin/gallery/[id]/edit/page.tsx b/src/app/admin/gallery/[id]/edit/page.tsx new file mode 100644 index 0000000..a44a9a6 --- /dev/null +++ b/src/app/admin/gallery/[id]/edit/page.tsx @@ -0,0 +1,84 @@ +import Link from 'next/link'; +import { prisma } from '@/lib/prisma'; +import AdminNav from '@/components/AdminNav'; +import { updateGalleryPhoto } from '../../actions'; +import { notFound } from 'next/navigation'; + +export default async function EditGalleryPhotoPage({ params }: { params: { id: string } }) { + const photo = await prisma.galleryPhoto.findUnique({ + where: { id: params.id }, + include: { category: true }, + }); + + if (!photo) { + notFound(); + } + + const categories = await prisma.galleryCategory.findMany({ orderBy: { name: 'asc' } }); + + return ( +
+ +

Edit photo

+

+ Update the caption and category for this gallery photo. +

+ +
+ +
+
+ + +
+ + +
+ +
+ + + + + Add a new category + +
+ +
+ + + Cancel + +
+
+
+
+
+ ); +} diff --git a/src/app/admin/gallery/actions.ts b/src/app/admin/gallery/actions.ts index 799e4ef..99d3e2e 100644 --- a/src/app/admin/gallery/actions.ts +++ b/src/app/admin/gallery/actions.ts @@ -49,6 +49,24 @@ export async function uploadGalleryPhoto(formData: FormData) { redirect('/admin/gallery'); } +export async function updateGalleryPhoto(formData: FormData) { + const id = String(formData.get('id') ?? ''); + const caption = String(formData.get('caption') ?? '').trim(); + const categoryId = String(formData.get('categoryId') ?? '').trim() || null; + + if (!id) return; + + await prisma.galleryPhoto.update({ + where: { id }, + data: { + caption: caption || null, + categoryId, + }, + }); + + redirect('/admin/gallery'); +} + export async function deleteGalleryPhoto(formData: FormData) { const id = String(formData.get('id') ?? ''); if (!id) return; diff --git a/src/app/admin/gallery/page.tsx b/src/app/admin/gallery/page.tsx index 6fcb293..fa0c542 100644 --- a/src/app/admin/gallery/page.tsx +++ b/src/app/admin/gallery/page.tsx @@ -53,12 +53,20 @@ export default async function AdminGalleryPage() { ) : ( Uncategorized )} -
- - -
+
+ + Edit + +
+ + +
+
))}
diff --git a/src/app/admin/reviews/page.tsx b/src/app/admin/reviews/page.tsx new file mode 100644 index 0000000..9bf630e --- /dev/null +++ b/src/app/admin/reviews/page.tsx @@ -0,0 +1,111 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { prisma } from '@/lib/prisma'; +import AdminNav from '@/components/AdminNav'; + +async function approveReview(reviewId: string) { + 'use server'; + await prisma.review.update({ + where: { id: reviewId }, + data: { approved: true }, + }); + redirect('/admin/reviews?status=pending'); +} + +async function deleteReview(reviewId: string) { + 'use server'; + await prisma.review.delete({ + where: { id: reviewId }, + }); + redirect('/admin/reviews?status=pending'); +} + +export default async function AdminReviewsPage({ searchParams }: { searchParams: { status?: string } }) { + const status = searchParams.status === 'approved' ? 'approved' : 'pending'; + + const reviews = await prisma.review.findMany({ + where: { + approved: status === 'approved', + }, + include: { product: true, customer: true }, + orderBy: { createdAt: 'desc' }, + }); + + return ( +
+ +

Customer Reviews

+ +
+ + Pending ({reviews.length}) + + + Approved + +
+ + {reviews.length === 0 ? ( +

No {status} reviews yet.

+ ) : ( +
+ {reviews.map((review) => ( +
+
+
+
+

{review.title}

+ + {'★'.repeat(review.rating)} + +
+

+ {review.customerName || review.customerEmail} • {review.product.name} +

+

{review.comment}

+

+ {new Date(review.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })} +

+
+
+ {!review.approved && ( +
+ +
+ )} +
+ +
+
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/api/designs/[id]/approve/route.ts b/src/app/api/designs/[id]/approve/route.ts index e0ed3e4..8f5d993 100644 --- a/src/app/api/designs/[id]/approve/route.ts +++ b/src/app/api/designs/[id]/approve/route.ts @@ -8,6 +8,9 @@ export async function POST( { params }: { params: { id: string } } ) { try { + const body = await req.json(); + const { expectedDeliveryDate } = body; + const design = await prisma.designApproval.findUnique({ where: { id: params.id }, include: { product: true }, @@ -19,7 +22,10 @@ export async function POST( const updated = await prisma.designApproval.update({ where: { id: params.id }, - data: { status: 'APPROVED' }, + data: { + status: 'APPROVED', + expectedDeliveryDate: expectedDeliveryDate ? new Date(expectedDeliveryDate) : null, + }, include: { product: true }, }); diff --git a/src/app/api/orders/[id]/design-spec/route.ts b/src/app/api/orders/[id]/design-spec/route.ts index fb72231..b7eaf80 100644 --- a/src/app/api/orders/[id]/design-spec/route.ts +++ b/src/app/api/orders/[id]/design-spec/route.ts @@ -71,6 +71,16 @@ export async function GET(req: Request, { params }: { params: { id: string } }) // Always show the section if we have preview images, even without elements if ((!designElements || designElements.length === 0) && !placement && !designPreview) return ''; + // Count images to create image numbering + const imageElements = designElements ? designElements.filter(el => el.type === 'image') : []; + const imageMap = new Map(); // Maps element index to image number + let imageCount = 0; + designElements?.forEach((el, idx) => { + if (el.type === 'image') { + imageMap.set(idx, ++imageCount); + } + }); + return `

${side} Design

@@ -126,10 +136,16 @@ export async function GET(req: Request, { params }: { params: { id: string } }) Y (0cm) - ${designElements && designElements.length > 0 ? designElements.map((el, idx) => ` - - E${idx + 1} - `).join('') : ''} + ${designElements && designElements.length > 0 ? designElements.map((el, idx) => { + const isImage = el.type === 'image'; + const imageNum = imageMap.get(idx); + const label = isImage ? `IMG${imageNum}` : `E${idx + 1}`; + const color = isImage ? 'purple' : 'orange'; + return ` + + ${label} + `; + }).join('') : ''}
@@ -138,7 +154,8 @@ export async function GET(req: Request, { params }: { params: { id: string } })

━ Green line: Y-axis (vertical, 0cm at top edge)

┊ Brown dashed lines: Position guides from edges to element center

T: / L: Distance from top and left edges of garment in cm

-

● Orange circles: Design element positions (E1, E2, etc.)

+

● Orange circles: Text elements (E1, E2, etc.)

+

● Purple circles: Image elements (IMG1, IMG2, etc.)

` : ''} @@ -157,9 +174,13 @@ export async function GET(req: Request, { params }: { params: { id: string } }) - ${designElements.map((el, idx) => ` + ${designElements.map((el, idx) => { + const isImage = el.type === 'image'; + const imageNum = imageMap.get(idx); + const label = isImage ? `Image ${imageNum}` : `E${idx + 1}`; + return ` - E${idx + 1} + ${label} ${el.type === 'text' ? '📝 Text' : '🖼️ Image'} X: ${convertPositionToCm(el.x, printAreaStartX)} | Y: ${convertPositionToCm(el.y, printAreaStartY)} X: ${Math.round(el.x - printAreaStartX)} | Y: ${Math.round(el.y - printAreaStartY)} @@ -176,7 +197,7 @@ export async function GET(req: Request, { params }: { params: { id: string } }) `} - `).join('')} + `}).join('')} ` : `

No individual design elements to measure.

`} diff --git a/src/app/api/reviews/[id]/route.ts b/src/app/api/reviews/[id]/route.ts new file mode 100644 index 0000000..2898112 --- /dev/null +++ b/src/app/api/reviews/[id]/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function DELETE( + req: Request, + { params }: { params: { id: string } } +) { + try { + const review = await prisma.review.delete({ + where: { id: params.id }, + }); + + return NextResponse.json(review); + } catch (err) { + console.error('Error deleting review:', err); + return NextResponse.json( + { error: 'Failed to delete review' }, + { status: 500 } + ); + } +} + +export async function PATCH( + req: Request, + { params }: { params: { id: string } } +) { + try { + const body = await req.json(); + const { approved } = body; + + const review = await prisma.review.update({ + where: { id: params.id }, + data: { approved }, + }); + + return NextResponse.json(review); + } catch (err) { + console.error('Error updating review:', err); + return NextResponse.json( + { error: 'Failed to update review' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/reviews/route.ts b/src/app/api/reviews/route.ts new file mode 100644 index 0000000..4387a58 --- /dev/null +++ b/src/app/api/reviews/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentCustomer } from '@/lib/auth'; + +export async function POST(req: Request) { + try { + const customer = await getCurrentCustomer(); + const body = await req.json(); + const { productId, rating, title, comment } = body; + + if (!productId || !rating || !title || !comment) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + if (rating < 1 || rating > 5) { + return NextResponse.json({ error: 'Rating must be between 1 and 5' }, { status: 400 }); + } + + const review = await prisma.review.create({ + data: { + customerId: customer?.id || null, + customerEmail: customer?.email || body.customerEmail || 'guest@example.com', + customerName: customer?.name || body.customerName || null, + productId, + rating, + title, + comment, + approved: false, + }, + }); + + return NextResponse.json(review); + } catch (err) { + console.error('Error creating review:', err); + return NextResponse.json( + { error: 'Failed to create review' }, + { status: 500 } + ); + } +} + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const productId = searchParams.get('productId'); + const approved = searchParams.get('approved') === 'true'; + + const reviews = await prisma.review.findMany({ + where: { + ...(productId && { productId }), + approved, + }, + include: { product: true }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json(reviews); + } catch (err) { + console.error('Error fetching reviews:', err); + return NextResponse.json( + { error: 'Failed to fetch reviews' }, + { status: 500 } + ); + } +} diff --git a/src/app/products/[slug]/reviews/page.tsx b/src/app/products/[slug]/reviews/page.tsx new file mode 100644 index 0000000..55ebd6b --- /dev/null +++ b/src/app/products/[slug]/reviews/page.tsx @@ -0,0 +1,100 @@ +import Link from 'next/link'; +import { prisma } from '@/lib/prisma'; +import ReviewForm from '@/components/ReviewForm'; +import { notFound } from 'next/navigation'; + +export default async function ProductReviewsPage({ params }: { params: { slug: string } }) { + const product = await prisma.product.findUnique({ + where: { slug: params.slug }, + include: { + reviews: { + where: { approved: true }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!product) { + notFound(); + } + + const avgRating = product.reviews.length > 0 + ? (product.reviews.reduce((sum, r) => sum + r.rating, 0) / product.reviews.length).toFixed(1) + : null; + + const ratingCounts = [5, 4, 3, 2, 1].map(rating => ({ + rating, + count: product.reviews.filter(r => r.rating === rating).length, + })); + + return ( +
+ + ← Back to product + + +

{product.name} Reviews

+ + {/* Rating Summary */} + {product.reviews.length > 0 && ( +
+
+
+

{avgRating}

+

{product.reviews.length} review{product.reviews.length !== 1 ? 's' : ''}

+
+
+ {ratingCounts.map(({ rating, count }) => ( +
+ {rating}★ +
+
0 ? (count / product.reviews.length) * 100 : 0}%`, + }} + /> +
+ {count} +
+ ))} +
+
+
+ )} + + {/* Leave a Review */} +
+

Leave a review

+ +
+ + {/* Reviews List */} +
+

Customer reviews

+ {product.reviews.length === 0 ? ( +

No reviews yet. Be the first to review this product!

+ ) : ( +
+ {product.reviews.map((review) => ( +
+
+
+

{review.title}

+

+ {review.customerName || 'Anonymous'} • {'★'.repeat(review.rating)} +

+
+
+

{review.comment}

+

+ {new Date(review.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })} +

+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/components/DesignCanvas.tsx b/src/components/DesignCanvas.tsx index 465a8ef..0c10706 100644 --- a/src/components/DesignCanvas.tsx +++ b/src/components/DesignCanvas.tsx @@ -1689,6 +1689,9 @@ export default function DesignCanvas({ )} {submissionMessage &&

{submissionMessage}

} {justAdded &&

Added to your bag.

} +

+ 💡 Color note: There may be slight color differences based on lighting and screen settings for personalized items. Your actual item may vary slightly from the preview shown. +

diff --git a/src/components/ReviewForm.tsx b/src/components/ReviewForm.tsx new file mode 100644 index 0000000..02beee8 --- /dev/null +++ b/src/components/ReviewForm.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +interface ReviewFormProps { + productId: string; + productName: string; + onSuccess?: () => void; +} + +export default function ReviewForm({ productId, productName, onSuccess }: ReviewFormProps) { + const router = useRouter(); + const [rating, setRating] = useState(5); + const [title, setTitle] = useState(''); + const [comment, setComment] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!title.trim() || !comment.trim()) { + setError('Please fill in all fields'); + return; + } + + setSubmitting(true); + try { + const res = await fetch('/api/reviews', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + productId, + rating, + title: title.trim(), + comment: comment.trim(), + }), + }); + + if (!res.ok) { + throw new Error('Failed to submit review'); + } + + setSuccess(true); + setTitle(''); + setComment(''); + setRating(5); + + if (onSuccess) { + onSuccess(); + } else { + setTimeout(() => { + router.refresh(); + }, 2000); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong'); + } finally { + setSubmitting(false); + } + }; + + if (success) { + return ( +
+ ✓ Thank you! Your review will be displayed after approval. +
+ ); + } + + return ( +
+ {error && ( +

+ {error} +

+ )} + +
+ +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+
+ +
+ + setTitle(e.target.value)} + placeholder="e.g., Excellent quality!" + className="w-full border border-line bg-paper px-3 py-2 text-sm" + maxLength={100} + /> +
+ +
+ +