diff --git a/packages/admin/src/components/AdminLayout.tsx b/packages/admin/src/components/AdminLayout.tsx index ec2abc5..2c76e2b 100644 --- a/packages/admin/src/components/AdminLayout.tsx +++ b/packages/admin/src/components/AdminLayout.tsx @@ -51,6 +51,7 @@ const navItems: NavItem[] = [ { path: '/design/branding', label: 'Branding' }, { path: '/design/theme', label: 'Theme' }, { path: '/design/templates', label: 'Templates' }, + { path: '/design/gallery', label: 'Gallery' }, ], }, { diff --git a/packages/admin/src/main.tsx b/packages/admin/src/main.tsx index cfc9b29..5e98c54 100644 --- a/packages/admin/src/main.tsx +++ b/packages/admin/src/main.tsx @@ -34,6 +34,7 @@ import DesignLanding from './pages/DesignLanding.js'; import DesignBranding from './pages/DesignBranding.js'; import DesignTheme from './pages/DesignTheme.js'; import DesignTemplates from './pages/DesignTemplates.js'; +import DesignGallery from './pages/DesignGallery.js'; import StaffList from './pages/StaffList.js'; import StaffInvite from './pages/StaffInvite.js'; import StaffEdit from './pages/StaffEdit.js'; @@ -108,6 +109,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/admin/src/pages/DesignGallery.tsx b/packages/admin/src/pages/DesignGallery.tsx new file mode 100644 index 0000000..9038836 --- /dev/null +++ b/packages/admin/src/pages/DesignGallery.tsx @@ -0,0 +1,313 @@ +import { useEffect, useState } from 'react'; + +type Category = 'FOOD' | 'INTERIOR' | 'GARDEN' | 'EVENTS'; + +interface GalleryImage { + id: string; + url: string; + alt: string; + category: Category; + sortOrder: number; + isActive: boolean; +} + +const CATEGORIES: Category[] = ['FOOD', 'INTERIOR', 'GARDEN', 'EVENTS']; + +const CATEGORY_LABELS: Record = { + FOOD: 'Food', + INTERIOR: 'Interior', + GARDEN: 'Garden', + EVENTS: 'Events', +}; + +interface FormState { + url: string; + alt: string; + category: Category; + sortOrder: number; + isActive: boolean; +} + +const emptyForm: FormState = { + url: '', + alt: '', + category: 'FOOD', + sortOrder: 0, + isActive: true, +}; + +export default function DesignGallery() { + const [images, setImages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [filter, setFilter] = useState('ALL'); + const [editing, setEditing] = useState(null); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState(emptyForm); + const [saving, setSaving] = useState(false); + + const token = localStorage.getItem('token') || ''; + const authHeaders = { Authorization: `Bearer ${token}` }; + + async function load() { + setLoading(true); + setError(''); + try { + const res = await fetch('/api/gallery/admin', { headers: authHeaders }); + if (!res.ok) throw new Error('Failed to load gallery'); + const result = await res.json(); + setImages(result.data ?? []); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + function openCreate() { + setEditing(null); + setForm(emptyForm); + setShowForm(true); + } + + function openEdit(img: GalleryImage) { + setEditing(img); + setForm({ + url: img.url, + alt: img.alt, + category: img.category, + sortOrder: img.sortOrder, + isActive: img.isActive, + }); + setShowForm(true); + } + + function closeForm() { + setShowForm(false); + setEditing(null); + setForm(emptyForm); + } + + async function save(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setError(''); + try { + const url = editing ? `/api/gallery/${editing.id}` : '/api/gallery'; + const method = editing ? 'PATCH' : 'POST'; + const res = await fetch(url, { + method, + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + if (!res.ok) { + const result = await res.json().catch(() => ({})); + throw new Error(result.error?.[0]?.message || 'Save failed'); + } + closeForm(); + await load(); + } catch (e: any) { + setError(e.message); + } finally { + setSaving(false); + } + } + + async function remove(img: GalleryImage) { + if (!window.confirm(`Delete "${img.alt}"?`)) return; + const res = await fetch(`/api/gallery/${img.id}`, { method: 'DELETE', headers: authHeaders }); + if (res.ok) await load(); + else setError('Delete failed'); + } + + async function toggleActive(img: GalleryImage) { + const res = await fetch(`/api/gallery/${img.id}`, { + method: 'PATCH', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: !img.isActive }), + }); + if (res.ok) await load(); + } + + const filtered = filter === 'ALL' ? images : images.filter((i) => i.category === filter); + + return ( +
+
+
+

Gallery

+

Manage photos shown on the storefront gallery page

+
+ +
+ +
+ {(['ALL', ...CATEGORIES] as const).map((c) => ( + + ))} +
+ + {error &&
{error}
} + + {loading ? ( +
+
+
+ ) : filtered.length === 0 ? ( +

No images yet. Click "Add Image" to start.

+ ) : ( +
+ {filtered.map((img) => ( +
+
+ {img.alt} + {!img.isActive && ( +
+ HIDDEN +
+ )} +
+
+

{img.alt}

+
+ {CATEGORY_LABELS[img.category]} · #{img.sortOrder} +
+
+ + + +
+
+
+ ))} +
+ )} + + {showForm && ( +
+
+
+
+

{editing ? 'Edit Image' : 'Add Image'}

+
+
+
+ + setForm({ ...form, url: e.target.value })} + placeholder="https://..." + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-primary-500 focus:border-primary-500" + /> +
+ {form.url && ( +
+ preview +
+ )} +
+ + setForm({ ...form, alt: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-primary-500 focus:border-primary-500" + /> +
+
+
+ + +
+
+ + setForm({ ...form, sortOrder: parseInt(e.target.value) || 0 })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+
+ +
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/packages/server/src/__tests__/integration/gallery.test.ts b/packages/server/src/__tests__/integration/gallery.test.ts new file mode 100644 index 0000000..003a904 --- /dev/null +++ b/packages/server/src/__tests__/integration/gallery.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import { createApp } from '../../app.js'; +import { generateToken } from '../../middleware/auth.js'; + +vi.mock('../../lib/db.js', () => { + const mockPrisma = { + galleryImage: { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), count: vi.fn() }, + user: { findUnique: vi.fn() }, + customer: { findUnique: vi.fn() }, + }; + return { default: mockPrisma, prisma: mockPrisma }; +}); + +vi.mock('../../lib/stripe.js', () => ({ + default: { + paymentIntents: { create: vi.fn() }, + webhooks: { constructEvent: vi.fn() }, + }, +})); + +import prisma from '../../lib/db.js'; +const mockedPrisma = vi.mocked(prisma); + +const app = createApp(); + +const staffToken = generateToken({ id: '1', email: 'admin@test.com', type: 'staff', role: 'SUPER_ADMIN' }); +const customerToken = generateToken({ id: 'cust-1', email: 'customer@test.com', type: 'customer' }); + +const sampleImage = { + id: 'img-1', + url: 'https://example.com/photo.jpg', + alt: 'Test photo', + category: 'FOOD', + sortOrder: 0, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('Gallery API', () => { + describe('GET /api/gallery (public)', () => { + it('returns active images without auth', async () => { + mockedPrisma.galleryImage.findMany.mockResolvedValueOnce([sampleImage] as any); + const res = await request(app).get('/api/gallery'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].category).toBe('FOOD'); + // Verify only active images are returned + const where = mockedPrisma.galleryImage.findMany.mock.calls[0][0]?.where; + expect(where).toMatchObject({ isActive: true }); + }); + + it('filters by category', async () => { + mockedPrisma.galleryImage.findMany.mockResolvedValueOnce([sampleImage] as any); + const res = await request(app).get('/api/gallery?category=FOOD'); + expect(res.status).toBe(200); + const where = mockedPrisma.galleryImage.findMany.mock.calls[0][0]?.where; + expect(where).toMatchObject({ isActive: true, category: 'FOOD' }); + }); + + it('ignores invalid category filter', async () => { + mockedPrisma.galleryImage.findMany.mockResolvedValueOnce([] as any); + const res = await request(app).get('/api/gallery?category=BOGUS'); + expect(res.status).toBe(200); + const where = mockedPrisma.galleryImage.findMany.mock.calls[0][0]?.where; + expect(where).toMatchObject({ isActive: true }); + expect((where as any).category).toBeUndefined(); + }); + }); + + describe('GET /api/gallery/admin', () => { + it('returns 401 without auth', async () => { + const res = await request(app).get('/api/gallery/admin'); + expect(res.status).toBe(401); + }); + + it('returns 403 for non-staff', async () => { + const res = await request(app) + .get('/api/gallery/admin') + .set('Authorization', `Bearer ${customerToken}`); + expect(res.status).toBe(403); + }); + + it('returns full list (including hidden) for staff', async () => { + mockedPrisma.galleryImage.findMany.mockResolvedValueOnce([ + sampleImage, + { ...sampleImage, id: 'img-2', isActive: false }, + ] as any); + const res = await request(app) + .get('/api/gallery/admin') + .set('Authorization', `Bearer ${staffToken}`); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + // No isActive filter on admin endpoint + const where = mockedPrisma.galleryImage.findMany.mock.calls[0][0]?.where; + expect(where).toBeUndefined(); + }); + }); + + describe('POST /api/gallery', () => { + it('returns 401 without auth', async () => { + const res = await request(app).post('/api/gallery').send({ + url: 'https://example.com/x.jpg', alt: 'x', category: 'FOOD', + }); + expect(res.status).toBe(401); + }); + + it('returns 403 for non-staff', async () => { + const res = await request(app) + .post('/api/gallery') + .set('Authorization', `Bearer ${customerToken}`) + .send({ url: 'https://example.com/x.jpg', alt: 'x', category: 'FOOD' }); + expect(res.status).toBe(403); + }); + + it('returns 400 for invalid url', async () => { + const res = await request(app) + .post('/api/gallery') + .set('Authorization', `Bearer ${staffToken}`) + .send({ url: 'not-a-url', alt: 'x', category: 'FOOD' }); + expect(res.status).toBe(400); + }); + + it('returns 400 for invalid category', async () => { + const res = await request(app) + .post('/api/gallery') + .set('Authorization', `Bearer ${staffToken}`) + .send({ url: 'https://example.com/x.jpg', alt: 'x', category: 'BOGUS' }); + expect(res.status).toBe(400); + }); + + it('creates image successfully', async () => { + mockedPrisma.galleryImage.create.mockResolvedValueOnce(sampleImage as any); + const res = await request(app) + .post('/api/gallery') + .set('Authorization', `Bearer ${staffToken}`) + .send({ url: 'https://example.com/photo.jpg', alt: 'Test photo', category: 'FOOD' }); + expect(res.status).toBe(201); + expect(res.body.data.id).toBe('img-1'); + }); + }); + + describe('PATCH /api/gallery/:id', () => { + it('returns 403 for non-staff', async () => { + const res = await request(app) + .patch('/api/gallery/img-1') + .set('Authorization', `Bearer ${customerToken}`) + .send({ alt: 'updated' }); + expect(res.status).toBe(403); + }); + + it('returns 404 when not found', async () => { + mockedPrisma.galleryImage.findUnique.mockResolvedValueOnce(null); + const res = await request(app) + .patch('/api/gallery/bad-id') + .set('Authorization', `Bearer ${staffToken}`) + .send({ alt: 'updated' }); + expect(res.status).toBe(404); + }); + + it('updates fields successfully', async () => { + mockedPrisma.galleryImage.findUnique.mockResolvedValueOnce(sampleImage as any); + mockedPrisma.galleryImage.update.mockResolvedValueOnce({ ...sampleImage, alt: 'updated' } as any); + const res = await request(app) + .patch('/api/gallery/img-1') + .set('Authorization', `Bearer ${staffToken}`) + .send({ alt: 'updated' }); + expect(res.status).toBe(200); + expect(res.body.data.alt).toBe('updated'); + }); + }); + + describe('DELETE /api/gallery/:id', () => { + it('returns 403 for non-staff', async () => { + const res = await request(app) + .delete('/api/gallery/img-1') + .set('Authorization', `Bearer ${customerToken}`); + expect(res.status).toBe(403); + }); + + it('returns 404 when not found', async () => { + mockedPrisma.galleryImage.findUnique.mockResolvedValueOnce(null); + const res = await request(app) + .delete('/api/gallery/bad-id') + .set('Authorization', `Bearer ${staffToken}`); + expect(res.status).toBe(404); + }); + + it('deletes image', async () => { + mockedPrisma.galleryImage.findUnique.mockResolvedValueOnce(sampleImage as any); + mockedPrisma.galleryImage.delete.mockResolvedValueOnce(sampleImage as any); + const res = await request(app) + .delete('/api/gallery/img-1') + .set('Authorization', `Bearer ${staffToken}`); + expect(res.status).toBe(200); + }); + }); +}); diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 01e5fdb..97a20e6 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -21,6 +21,7 @@ import consentRoutes from './routes/consent.routes.js'; import settingsRoutes from './routes/settings.routes.js'; import staffRoutes from './routes/staff.routes.js'; import developerRoutes from './routes/developer.routes.js'; +import galleryRoutes from './routes/gallery.routes.js'; import { openApiSpec } from './lib/openapi.js'; import { initPassport } from './lib/passport.js'; import passport from 'passport'; @@ -117,6 +118,7 @@ export function createApp() { app.use('/api/settings', settingsRoutes); app.use('/api/staff', staffRoutes); app.use('/api/developer', developerRoutes); + app.use('/api/gallery', galleryRoutes); // 404 handler app.use((_req, res) => { diff --git a/packages/server/src/controllers/gallery.controller.ts b/packages/server/src/controllers/gallery.controller.ts new file mode 100644 index 0000000..d4da895 --- /dev/null +++ b/packages/server/src/controllers/gallery.controller.ts @@ -0,0 +1,89 @@ +import { Request, Response } from 'express'; +import { z } from 'zod'; +import prisma from '../lib/db.js'; + +const CATEGORIES = ['FOOD', 'INTERIOR', 'GARDEN', 'EVENTS'] as const; + +const createSchema = z.object({ + url: z.string().url().max(2048), + alt: z.string().min(1).max(200), + category: z.enum(CATEGORIES), + sortOrder: z.number().int().optional(), + isActive: z.boolean().optional(), +}); + +const updateSchema = createSchema.partial(); + +export async function listPublicGallery(req: Request, res: Response): Promise { + const category = req.query.category as string | undefined; + const where: Record = { isActive: true }; + if (category && (CATEGORIES as readonly string[]).includes(category)) { + where.category = category; + } + + const images = await prisma.galleryImage.findMany({ + where, + orderBy: [{ category: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }], + }); + + res.json({ success: true, data: images }); +} + +export async function listAllGallery(_req: Request, res: Response): Promise { + const images = await prisma.galleryImage.findMany({ + orderBy: [{ category: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }], + }); + res.json({ success: true, data: images }); +} + +export async function createGalleryImage(req: Request, res: Response): Promise { + const parsed = createSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: parsed.error.errors }); + return; + } + + const image = await prisma.galleryImage.create({ + data: { + url: parsed.data.url, + alt: parsed.data.alt, + category: parsed.data.category, + sortOrder: parsed.data.sortOrder ?? 0, + isActive: parsed.data.isActive ?? true, + }, + }); + + res.status(201).json({ success: true, data: image }); +} + +export async function updateGalleryImage(req: Request<{ id: string }>, res: Response): Promise { + const parsed = updateSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: parsed.error.errors }); + return; + } + + const existing = await prisma.galleryImage.findUnique({ where: { id: req.params.id } }); + if (!existing) { + res.status(404).json({ success: false, error: 'Image not found' }); + return; + } + + const image = await prisma.galleryImage.update({ + where: { id: req.params.id }, + data: parsed.data, + }); + + res.json({ success: true, data: image }); +} + +export async function deleteGalleryImage(req: Request<{ id: string }>, res: Response): Promise { + const existing = await prisma.galleryImage.findUnique({ where: { id: req.params.id } }); + if (!existing) { + res.status(404).json({ success: false, error: 'Image not found' }); + return; + } + + await prisma.galleryImage.delete({ where: { id: req.params.id } }); + res.json({ success: true, message: 'Image deleted' }); +} diff --git a/packages/server/src/routes/gallery.routes.ts b/packages/server/src/routes/gallery.routes.ts new file mode 100644 index 0000000..b702e55 --- /dev/null +++ b/packages/server/src/routes/gallery.routes.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { authenticate, requireStaff } from '../middleware/auth.js'; +import { + listPublicGallery, + listAllGallery, + createGalleryImage, + updateGalleryImage, + deleteGalleryImage, +} from '../controllers/gallery.controller.js'; + +const router = Router(); + +// Public: list active gallery images (optionally filtered by category) +router.get('/', listPublicGallery); + +// Staff: full list + CRUD +router.get('/admin', authenticate, requireStaff, listAllGallery); +router.post('/', authenticate, requireStaff, createGalleryImage); +router.patch('/:id', authenticate, requireStaff, updateGalleryImage); +router.delete('/:id', authenticate, requireStaff, deleteGalleryImage); + +export default router; diff --git a/packages/storefront/src/components/Header.tsx b/packages/storefront/src/components/Header.tsx index dcc484a..d70e099 100644 --- a/packages/storefront/src/components/Header.tsx +++ b/packages/storefront/src/components/Header.tsx @@ -20,6 +20,7 @@ function ClassicHeader() { { to: '/', label: t('nav.home') }, { to: '/locations', label: t('nav.locations') }, { to: '/menu', label: t('nav.menu') }, + { to: '/gallery', label: t('nav.gallery') }, { to: '/reservations', label: t('nav.reservations') }, ]; diff --git a/packages/storefront/src/i18n/locales/de.json b/packages/storefront/src/i18n/locales/de.json index 91679d7..71f6c06 100644 --- a/packages/storefront/src/i18n/locales/de.json +++ b/packages/storefront/src/i18n/locales/de.json @@ -18,6 +18,7 @@ "locations": "Standorte", "menu": "Speisekarte", "reservations": "Reservierungen", + "gallery": "Galerie", "login": "Anmelden", "signUp": "Registrieren", "logout": "Abmelden", @@ -25,6 +26,16 @@ "openCart": "Warenkorb öffnen", "toggleMenu": "Menü umschalten" }, + "gallery": { + "title": "Galerie", + "subtitle": "Ein Blick in unser Restaurant — Küche, Ambiente, Garten und besondere Momente", + "all": "Alle", + "food": "Speisen", + "interior": "Innenbereich", + "garden": "Garten", + "events": "Events", + "empty": "Noch keine Fotos." + }, "home": { "heroTitle": "Leckeres Essen Online Bestellen", "heroDescription": "Stöbern Sie in unserer Speisekarte, bestellen Sie zur Lieferung oder Abholung und genießen Sie frisch zubereitete Gerichte von KitchenAsty.", diff --git a/packages/storefront/src/i18n/locales/en.json b/packages/storefront/src/i18n/locales/en.json index 4ebb745..2b58ea1 100644 --- a/packages/storefront/src/i18n/locales/en.json +++ b/packages/storefront/src/i18n/locales/en.json @@ -18,6 +18,7 @@ "locations": "Locations", "menu": "Menu", "reservations": "Reservations", + "gallery": "Gallery", "login": "Login", "signUp": "Sign Up", "logout": "Logout", @@ -25,6 +26,16 @@ "openCart": "Open cart", "toggleMenu": "Toggle menu" }, + "gallery": { + "title": "Gallery", + "subtitle": "A peek inside our restaurant — food, ambiance, garden and special moments", + "all": "All", + "food": "Food", + "interior": "Interior", + "garden": "Garden", + "events": "Events", + "empty": "No photos yet." + }, "home": { "heroTitle": "Order Delicious Food Online", "heroDescription": "Browse our menu, place your order for delivery or pickup, and enjoy fresh, made-to-order meals from KitchenAsty.", diff --git a/packages/storefront/src/i18n/locales/es.json b/packages/storefront/src/i18n/locales/es.json index d30e8db..04221e0 100644 --- a/packages/storefront/src/i18n/locales/es.json +++ b/packages/storefront/src/i18n/locales/es.json @@ -18,6 +18,7 @@ "locations": "Ubicaciones", "menu": "Menú", "reservations": "Reservas", + "gallery": "Galería", "login": "Iniciar Sesión", "signUp": "Registrarse", "logout": "Cerrar Sesión", @@ -25,6 +26,16 @@ "openCart": "Abrir carrito", "toggleMenu": "Alternar menú" }, + "gallery": { + "title": "Galería", + "subtitle": "Una mirada dentro de nuestro restaurante — comida, ambiente, jardín y momentos especiales", + "all": "Todo", + "food": "Comida", + "interior": "Interior", + "garden": "Jardín", + "events": "Eventos", + "empty": "Aún no hay fotos." + }, "home": { "heroTitle": "Pide Comida Deliciosa en Línea", "heroDescription": "Explora nuestro menú, haz tu pedido para entrega o recogida, y disfruta de comidas frescas y preparadas al momento de KitchenAsty.", diff --git a/packages/storefront/src/i18n/locales/fr.json b/packages/storefront/src/i18n/locales/fr.json index 06e75c0..f93e73c 100644 --- a/packages/storefront/src/i18n/locales/fr.json +++ b/packages/storefront/src/i18n/locales/fr.json @@ -18,6 +18,7 @@ "locations": "Restaurants", "menu": "Menu", "reservations": "Réservations", + "gallery": "Galerie", "login": "Connexion", "signUp": "S'inscrire", "logout": "Déconnexion", @@ -25,6 +26,16 @@ "openCart": "Ouvrir le panier", "toggleMenu": "Basculer le menu" }, + "gallery": { + "title": "Galerie", + "subtitle": "Un aperçu de notre restaurant — cuisine, ambiance, jardin et moments d'exception", + "all": "Tout", + "food": "Cuisine", + "interior": "Intérieur", + "garden": "Jardin", + "events": "Événements", + "empty": "Aucune photo pour le moment." + }, "home": { "heroTitle": "Commandez de Bons Plats en Ligne", "heroDescription": "Parcourez notre menu, passez votre commande en livraison ou à emporter, et savourez des plats frais préparés à la demande chez KitchenAsty.", diff --git a/packages/storefront/src/i18n/locales/it.json b/packages/storefront/src/i18n/locales/it.json index 3738be9..5d592c5 100644 --- a/packages/storefront/src/i18n/locales/it.json +++ b/packages/storefront/src/i18n/locales/it.json @@ -18,6 +18,7 @@ "locations": "Sedi", "menu": "Menu", "reservations": "Prenotazioni", + "gallery": "Galleria", "login": "Accedi", "signUp": "Registrati", "logout": "Esci", @@ -25,6 +26,16 @@ "openCart": "Apri carrello", "toggleMenu": "Apri/chiudi menu" }, + "gallery": { + "title": "Galleria", + "subtitle": "Uno sguardo dentro il nostro ristorante — cucina, atmosfera, giardino e momenti speciali", + "all": "Tutto", + "food": "Cucina", + "interior": "Interno", + "garden": "Giardino", + "events": "Eventi", + "empty": "Ancora nessuna foto." + }, "home": { "heroTitle": "Ordina Cibo Delizioso Online", "heroDescription": "Sfoglia il nostro menu, ordina per consegna o ritiro e gusta piatti freschi preparati al momento da KitchenAsty.", diff --git a/packages/storefront/src/i18n/locales/pt.json b/packages/storefront/src/i18n/locales/pt.json index ca3350b..8b57abc 100644 --- a/packages/storefront/src/i18n/locales/pt.json +++ b/packages/storefront/src/i18n/locales/pt.json @@ -18,6 +18,7 @@ "locations": "Localizações", "menu": "Cardápio", "reservations": "Reservas", + "gallery": "Galeria", "login": "Entrar", "signUp": "Cadastrar", "logout": "Sair", @@ -25,6 +26,16 @@ "openCart": "Abrir carrinho", "toggleMenu": "Alternar menu" }, + "gallery": { + "title": "Galeria", + "subtitle": "Um vislumbre do nosso restaurante — comida, ambiente, jardim e momentos especiais", + "all": "Tudo", + "food": "Comida", + "interior": "Interior", + "garden": "Jardim", + "events": "Eventos", + "empty": "Ainda sem fotos." + }, "home": { "heroTitle": "Peça Comida Deliciosa Online", "heroDescription": "Navegue pelo nosso cardápio, faça seu pedido para entrega ou retirada e aproveite refeições frescas e preparadas na hora pelo KitchenAsty.", diff --git a/packages/storefront/src/main.tsx b/packages/storefront/src/main.tsx index 3a9f553..5f0e015 100644 --- a/packages/storefront/src/main.tsx +++ b/packages/storefront/src/main.tsx @@ -14,6 +14,7 @@ import Menu from './pages/Menu.js'; import Checkout from './pages/Checkout.js'; import OrderConfirmation from './pages/OrderConfirmation.js'; import Reservations from './pages/Reservations.js'; +import Gallery from './pages/Gallery.js'; import OrderHistory from './pages/OrderHistory.js'; import OrderStatus from './pages/OrderStatus.js'; import AuthCallback from './pages/AuthCallback.js'; @@ -35,6 +36,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/storefront/src/pages/Gallery.tsx b/packages/storefront/src/pages/Gallery.tsx new file mode 100644 index 0000000..707b263 --- /dev/null +++ b/packages/storefront/src/pages/Gallery.tsx @@ -0,0 +1,130 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useApi } from '../hooks/useApi.js'; + +type GalleryCategory = 'FOOD' | 'INTERIOR' | 'GARDEN' | 'EVENTS'; + +interface GalleryImage { + id: string; + url: string; + alt: string; + category: GalleryCategory; + sortOrder: number; +} + +type Filter = 'ALL' | GalleryCategory; + +const FILTER_KEYS: Record = { + ALL: 'gallery.all', + FOOD: 'gallery.food', + INTERIOR: 'gallery.interior', + GARDEN: 'gallery.garden', + EVENTS: 'gallery.events', +}; + +const FILTERS: Filter[] = ['ALL', 'FOOD', 'INTERIOR', 'GARDEN', 'EVENTS']; + +export default function Gallery() { + const { t } = useTranslation(); + const { data: images, error, isLoading } = useApi('/api/gallery'); + const [filter, setFilter] = useState('ALL'); + const [lightbox, setLightbox] = useState(null); + + const filtered = useMemo(() => { + if (!images) return []; + if (filter === 'ALL') return images; + return images.filter((img) => img.category === filter); + }, [images, filter]); + + return ( +
+
+

{t('gallery.title')}

+

{t('gallery.subtitle')}

+
+ + {/* Filter chips */} +
+ {FILTERS.map((f) => ( + + ))} +
+ + {isLoading && ( +
+
+
+ )} + + {error && ( +
+ {t('common.error')} +
+ )} + + {!isLoading && !error && filtered.length === 0 && ( +

{t('gallery.empty')}

+ )} + + {filtered.length > 0 && ( +
+ {filtered.map((img) => ( + + ))} +
+ )} + + {lightbox && ( +
setLightbox(null)} + role="dialog" + aria-modal="true" + aria-label={lightbox.alt} + > + + {lightbox.alt} e.stopPropagation()} + /> +
+ )} +
+ ); +} diff --git a/packages/storefront/src/templates/headers/ElegantHeader.tsx b/packages/storefront/src/templates/headers/ElegantHeader.tsx index ea418fa..c1f5c0f 100644 --- a/packages/storefront/src/templates/headers/ElegantHeader.tsx +++ b/packages/storefront/src/templates/headers/ElegantHeader.tsx @@ -18,6 +18,7 @@ export default function ElegantHeader() { { to: '/', label: t('nav.home') }, { to: '/locations', label: t('nav.locations') }, { to: '/menu', label: t('nav.menu') }, + { to: '/gallery', label: t('nav.gallery') }, { to: '/reservations', label: t('nav.reservations') }, ]; diff --git a/packages/storefront/src/templates/headers/useHeaderProps.ts b/packages/storefront/src/templates/headers/useHeaderProps.ts index fd72d13..9f12105 100644 --- a/packages/storefront/src/templates/headers/useHeaderProps.ts +++ b/packages/storefront/src/templates/headers/useHeaderProps.ts @@ -17,6 +17,7 @@ export function useHeaderProps() { { to: '/', label: t('nav.home') }, { to: '/locations', label: t('nav.locations') }, { to: '/menu', label: t('nav.menu') }, + { to: '/gallery', label: t('nav.gallery') }, { to: '/reservations', label: t('nav.reservations') }, ]; diff --git a/prisma/migrations/20260514100737_add_gallery_images/migration.sql b/prisma/migrations/20260514100737_add_gallery_images/migration.sql new file mode 100644 index 0000000..29a9f54 --- /dev/null +++ b/prisma/migrations/20260514100737_add_gallery_images/migration.sql @@ -0,0 +1,19 @@ +-- CreateEnum +CREATE TYPE "GalleryCategory" AS ENUM ('FOOD', 'INTERIOR', 'GARDEN', 'EVENTS'); + +-- CreateTable +CREATE TABLE "gallery_images" ( + "id" TEXT NOT NULL, + "url" TEXT NOT NULL, + "alt" TEXT NOT NULL, + "category" "GalleryCategory" NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "gallery_images_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "gallery_images_category_sortOrder_idx" ON "gallery_images"("category", "sortOrder"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 049f592..ee3aba7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -606,6 +606,27 @@ model SiteSettings { @@map("site_settings") } +enum GalleryCategory { + FOOD + INTERIOR + GARDEN + EVENTS +} + +model GalleryImage { + id String @id @default(cuid()) + url String + alt String + category GalleryCategory + sortOrder Int @default(0) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([category, sortOrder]) + @@map("gallery_images") +} + // ============================================================ // AUTOMATION // ============================================================ diff --git a/prisma/seed.ts b/prisma/seed.ts index f5d0a56..6ffe4e3 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -687,6 +687,42 @@ KitchenAsty Management Team`, }); } + // Gallery images + const galleryImages = [ + // FOOD + { url: 'https://images.unsplash.com/photo-1565557623262-b51c2513a641?w=1200&q=80&auto=format&fit=crop', alt: 'Butter chicken with naan', category: 'FOOD', sortOrder: 0 }, + { url: 'https://images.unsplash.com/photo-1631452180519-c014fe946bc7?w=1200&q=80&auto=format&fit=crop', alt: 'Aromatic biryani platter', category: 'FOOD', sortOrder: 1 }, + { url: 'https://images.unsplash.com/photo-1567188040759-fb8a883dc6d8?w=1200&q=80&auto=format&fit=crop', alt: 'Traditional Indian curry', category: 'FOOD', sortOrder: 2 }, + { url: 'https://images.unsplash.com/photo-1601050690597-df0568f70950?w=1200&q=80&auto=format&fit=crop', alt: 'Tandoori starters platter', category: 'FOOD', sortOrder: 3 }, + // INTERIOR + { url: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=1200&q=80&auto=format&fit=crop', alt: 'Warm dining room', category: 'INTERIOR', sortOrder: 0 }, + { url: 'https://images.unsplash.com/photo-1559339352-11d035aa65de?w=1200&q=80&auto=format&fit=crop', alt: 'Cocktail bar at dusk', category: 'INTERIOR', sortOrder: 1 }, + { url: 'https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=1200&q=80&auto=format&fit=crop', alt: 'Candlelit table for two', category: 'INTERIOR', sortOrder: 2 }, + // GARDEN + { url: 'https://images.unsplash.com/photo-1499028344343-cd173ffc68a9?w=1200&q=80&auto=format&fit=crop', alt: 'Lush garden seating', category: 'GARDEN', sortOrder: 0 }, + { url: 'https://images.unsplash.com/photo-1466978913421-dad2ebd01d17?w=1200&q=80&auto=format&fit=crop', alt: 'Outdoor terrace at golden hour', category: 'GARDEN', sortOrder: 1 }, + { url: 'https://images.unsplash.com/photo-1533777324565-a040eb52facd?w=1200&q=80&auto=format&fit=crop', alt: 'Garden patio with string lights', category: 'GARDEN', sortOrder: 2 }, + // EVENTS + { url: 'https://images.unsplash.com/photo-1530103862676-de8c9debad1d?w=1200&q=80&auto=format&fit=crop', alt: 'Private dining setup', category: 'EVENTS', sortOrder: 0 }, + { url: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?w=1200&q=80&auto=format&fit=crop', alt: 'Celebration dinner table', category: 'EVENTS', sortOrder: 1 }, + { url: 'https://images.unsplash.com/photo-1555244162-803834f70033?w=1200&q=80&auto=format&fit=crop', alt: 'Group toast at the bar', category: 'EVENTS', sortOrder: 2 }, + ] as const; + + for (const img of galleryImages) { + const seedId = `seed-gallery-${img.category}-${img.sortOrder}`; + await prisma.galleryImage.upsert({ + where: { id: seedId }, + update: { url: img.url, alt: img.alt }, + create: { + id: seedId, + url: img.url, + alt: img.alt, + category: img.category, + sortOrder: img.sortOrder, + }, + }); + } + console.log('Seed completed successfully!'); console.log(''); console.log('Admin login: admin@kitchenasty.com / admin123');