Initial commit - Warme catering website

This commit is contained in:
Alex Polo
2025-10-28 23:22:33 +01:00
parent a8b85b58be
commit 4acfa6a213
49 changed files with 4154 additions and 2075 deletions

28
ecosystem.config.js Normal file
View File

@@ -0,0 +1,28 @@
module.exports = {
apps: [
{
name: 'warme',
script: 'server.mjs',
instances: 1,
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000
},
env_development: {
NODE_ENV: 'development',
PORT: 3000
},
node_args: [
'--no-deprecation',
'--experimental-fetch'
],
error_file: 'logs/err.log',
out_file: 'logs/out.log',
time: true,
wait_ready: true,
listen_timeout: 10000,
kill_timeout: 5000
}
]
}

8
eslint.config.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
extends: [
'next/core-web-vitals',
],
rules: {
'@next/next/no-img-element': 'off',
},
}

31
next.config.mjs Normal file
View File

@@ -0,0 +1,31 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'warme.dk',
},
],
},
typescript: {
// Only run TypeScript checks in development
ignoreBuildErrors: process.env.NODE_ENV === 'production',
},
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
]
},
optimizeFonts: true,
};
export default nextConfig;

View File

@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

3674
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,22 +5,42 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"start": "node server.mjs",
"lint": "next lint",
"clean": "rimraf .next node_modules logs && npm install",
"typecheck": "tsc --noEmit",
"build-and-test": "bash scripts/build-and-test.sh",
"pm2:start": "pm2 start ecosystem.config.js",
"pm2:stop": "pm2 stop warme",
"pm2:restart": "pm2 restart warme",
"pm2:logs": "pm2 logs warme --lines 100 --nostream",
"pm2:delete": "pm2 delete warme"
},
"dependencies": {
"react": "19.2.0",
"react-dom": "19.2.0",
"next": "16.0.1"
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"lucide-react": "^0.292.0",
"next": "^14.2.33",
"react": "18.2.0",
"react-day-picker": "^8.9.1",
"react-dom": "18.2.0",
"tailwind-merge": "^2.0.0"
},
"devDependencies": {
"typescript": "^5",
"@eslint/config-array": "^0.21.1",
"@eslint/object-schema": "^2.1.7",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "16.0.1"
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.4.16",
"eslint": "^8.57.1",
"eslint-config-next": "14.0.3",
"glob": "^11.0.3",
"lru-cache": "^11.2.2",
"postcss": "^8.4.31",
"rimraf": "^6.0.1",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
'tailwindcss': {},
'autoprefixer': {},
}
}

BIN
public/img/brunch.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/img/fransk.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/img/grill.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/img/italiensk.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/img/jul.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/img/klassisk.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/img/nordisk.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/img/tapas.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/img/veg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

73
scripts/build-and-test.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/bin/bash
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}🚀 Starting build and test process...${NC}\n"
# Kill any running Next.js processes
echo -e "📌 Cleaning up previous processes..."
pkill -f "next" || true
# Remove build artifacts and dependencies
echo -e "🧹 Cleaning build artifacts..."
npx rimraf .next node_modules
# Install dependencies
echo -e "\n📦 Installing dependencies..."
if npm install; then
echo -e "${GREEN}✓ Dependencies installed successfully${NC}"
else
echo -e "${RED}✗ Failed to install dependencies${NC}"
exit 1
fi
# Type checking
echo -e "\n📝 Running type check..."
if npm run typecheck; then
echo -e "${GREEN}✓ Type check passed${NC}"
else
echo -e "${RED}✗ Type check failed${NC}"
exit 1
fi
# Build the application
echo -e "\n🏗 Building the application..."
if npm run build; then
echo -e "${GREEN}✓ Build successful${NC}"
else
echo -e "${RED}✗ Build failed${NC}"
exit 1
fi
# Start with PM2
echo -e "\n🚀 Starting application with PM2..."
pm2 delete warme 2>/dev/null || true
if pm2 start npm --name "warme" -- start; then
echo -e "${GREEN}✓ Application started successfully${NC}"
else
echo -e "${RED}✗ Failed to start application${NC}"
exit 1
fi
# Show logs
echo -e "\n📋 Recent application logs:"
pm2 logs warme --lines 20 --nostream
# Health check
echo -e "\n🏥 Performing health check..."
sleep 5 # Wait for app to fully start
if curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 | grep -q "200"; then
echo -e "${GREEN}✓ Application is responding${NC}"
else
echo -e "${RED}✗ Application is not responding${NC}"
# Show error logs
echo -e "\n❌ Error logs:"
pm2 logs warme --err --lines 20 --nostream
exit 1
fi
echo -e "\n${GREEN}✅ Build and test process completed successfully!${NC}"

29
server.mjs Normal file
View File

@@ -0,0 +1,29 @@
import { createServer } from 'http'
import { parse } from 'url'
import next from 'next'
const dev = process.env.NODE_ENV !== 'production'
const hostname = 'localhost'
const port = 3000
const app = next({ dev, hostname, port })
const handle = app.getRequestHandler()
app.prepare().then(() => {
createServer(async (req, res) => {
try {
const parsedUrl = parse(req.url, true)
await handle(req, res, parsedUrl)
} catch (err) {
console.error('Error occurred handling', req.url, err)
res.statusCode = 500
res.end('Internal Server Error')
}
})
.once('error', (err) => {
console.error(err)
process.exit(1)
})
.listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`)
})
})

View File

@@ -0,0 +1,154 @@
'use client'
import { useState } from 'react'
import { Calendar, Users, FileText, Settings, ChefHat } from 'lucide-react'
import { CalendarView } from '@/components/admin/calendar-view'
import { BookingsView } from '@/components/admin/bookings-view'
import { siteConfig } from '@/config/site'
const sections = [
{ id: 'calendar', icon: Calendar, label: 'Kalender' },
{ id: 'bookings', icon: Users, label: 'Bookinger' },
{ id: 'quotes', icon: FileText, label: 'Tilbud' },
{ id: 'settings', icon: Settings, label: 'Indstillinger' }
] as const
type Section = (typeof sections)[number]['id']
export default function AdminDashboard() {
const [activeSection, setActiveSection] = useState<Section>('calendar')
return (
<div className="min-h-screen bg-primary-50">
<div className="flex">
{/* Sidebar */}
<div className="w-64 bg-white min-h-screen border-r">
<div className="p-4 border-b">
<div className="flex items-center gap-2">
<ChefHat className="w-6 h-6 text-primary-600" />
<h1 className="text-lg font-semibold">{siteConfig.brand.displayName}</h1>
</div>
</div>
<nav className="space-y-1 p-2">
{sections.map(({ id, icon: Icon, label }) => (
<button
key={id}
onClick={() => setActiveSection(id)}
className={`w-full flex items-center gap-3 px-4 py-3 text-left rounded-lg transition-colors ${
activeSection === id
? 'bg-primary-100 text-primary-900 font-medium'
: 'hover:bg-primary-50'
}`}
>
<Icon className="w-5 h-5" />
{label}
</button>
))}
</nav>
</div>
{/* Main Content */}
<div className="flex-1 p-8">
<div className="max-w-6xl mx-auto">
{activeSection === 'calendar' && <CalendarView />}
{activeSection === 'bookings' && <BookingsView />}
{activeSection === 'quotes' && (
<div>
<header className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold flex items-center gap-2">
<FileText className="w-6 h-6" />
Tilbud
</h2>
</header>
<div className="space-y-4">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="font-medium text-lg mb-4">Aktive tilbud</h3>
<div className="space-y-4">
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div>
<p className="font-medium">Anders Andersen</p>
<p className="text-sm text-primary-600">Fødselsdag 45 kuverter</p>
</div>
<p className="text-lg font-bold">22.950 kr.</p>
</div>
<div className="flex gap-2">
<button className="text-sm px-3 py-1 bg-primary-100 text-primary-700 rounded-full">
Download PDF
</button>
<button className="text-sm px-3 py-1 bg-primary-100 text-primary-700 rounded-full">
Send email
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{activeSection === 'settings' && (
<div>
<header className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Settings className="w-6 h-6" />
Indstillinger
</h2>
</header>
<div className="space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">Priser og satser</h3>
<div className="grid md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium mb-1">
Timeløn (kok)
</label>
<input
type="number"
className="w-full px-3 py-2 border rounded-lg"
defaultValue={350}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Timeløn (hjælper)
</label>
<input
type="number"
className="w-full px-3 py-2 border rounded-lg"
defaultValue={200}
/>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">Integration</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">
iCal/CSV URL
</label>
<input
type="url"
className="w-full px-3 py-2 border rounded-lg"
placeholder="https://"
/>
</div>
<button className="px-4 py-2 bg-primary-100 text-primary-700 rounded-lg">
Test forbindelse
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import Link from 'next/link'
import { siteConfig } from '@/config/site'
import { sampleData } from '@/config/sample-data'
const navigation = [
{ name: 'Menuer', href: '/menuer' },
{ name: 'Pakker', href: '/pakker' },
{ name: 'Book', href: '/book' },
{ name: 'Kontakt', href: '/kontakt' },
]
export default function MarketingLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen flex flex-col">
<header className="bg-white border-b">
<nav className="container mx-auto px-4 h-16 flex items-center justify-between">
<Link href="/" className="font-bold text-lg">
{siteConfig.brand.displayName}
</Link>
<ul className="flex gap-6">
{navigation.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="text-primary-600 hover:text-primary-900"
>
{item.name}
</Link>
</li>
))}
</ul>
</nav>
</header>
<main className="flex-1">
{children}
</main>
<footer className="bg-primary-900 text-white py-12">
<div className="container mx-auto px-4">
<div className="grid md:grid-cols-3 gap-8">
<div>
<h3 className="font-bold mb-4">{siteConfig.brand.displayName}</h3>
<p className="text-primary-300">{siteConfig.brand.tagline}</p>
</div>
<div>
<h3 className="font-bold mb-4">Navigation</h3>
<ul className="space-y-2">
{navigation.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="text-primary-300 hover:text-white"
>
{item.name}
</Link>
</li>
))}
</ul>
</div>
<div>
<h3 className="font-bold mb-4">Kontakt</h3>
<ul className="space-y-2 text-primary-300">
<li>{sampleData.chef.contact.phone}</li>
<li>{sampleData.chef.contact.email}</li>
</ul>
</div>
</div>
<div className="mt-8 pt-8 border-t border-primary-800 text-center text-primary-400">
<p>© {new Date().getFullYear()} {siteConfig.brand.displayName}</p>
</div>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { Metadata } from 'next'
import { siteConfig } from '@/config/site'
import { Hero } from '@/components/marketing/hero'
import { USPList } from '@/components/marketing/usp-list'
import { FeaturedMenus } from '@/components/marketing/featured-menus'
import { CTASection } from '@/components/marketing/cta-section'
export const metadata: Metadata = {
title: siteConfig.brand.displayName + ' | Privat Kok i København',
description: 'Professionel privat kok til din næste fest. Højt service niveau og rustikke gourmét retter tilberedt i dit hjem. Perfekt til selskaber på 2050 personer.',
openGraph: {
title: siteConfig.brand.displayName + ' | Privat Kok i København',
description: 'Professionel privat kok til din næste fest. Højt service niveau og rustikke gourmét retter tilberedt i dit hjem.',
url: siteConfig.domain,
siteName: siteConfig.brand.displayName,
locale: siteConfig.locale,
type: 'website',
},
}
export default function HomePage() {
return (
<div className="flex flex-col min-h-screen">
<main className="flex-grow">
<Hero />
<section className="py-16 bg-primary-50">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold text-center mb-12">
Hvorfor vælge {siteConfig.brand.displayName}?
</h2>
<USPList />
</div>
</section>
<FeaturedMenus />
<CTASection />
</main>
</div>
)
}

View File

@@ -0,0 +1,31 @@
export default function TermsPage() {
const terms = [
"Kuvertpriser gælder ved min. 20 kuverter",
"Endeligt tilbud fremsendes på mail",
"Aflysning: senest 7 dage før (ellers 50% af tilbudssum)",
"Allergener og specialkost aftales senest 5 dage før"
]
return (
<div className="container mx-auto px-4 py-12">
<div className="max-w-2xl mx-auto">
<h1 className="text-4xl font-bold mb-6">Betingelser</h1>
<div className="bg-white rounded-lg shadow-lg p-6">
<ul className="space-y-4">
{terms.map((term, index) => (
<li
key={index}
className="flex items-start gap-4 text-lg"
>
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-accent text-white flex items-center justify-center font-medium">
{index + 1}
</span>
{term}
</li>
))}
</ul>
</div>
</div>
</div>
)
}

147
src/app/book/page.tsx Normal file
View File

@@ -0,0 +1,147 @@
'use client'
import { DatePicker } from '@/components/ui/date-picker'
import { NumberInput } from '@/components/ui/number-input'
import { Input } from '@/components/ui/input'
import { sampleData } from '@/config/sample-data'
import { Package, Menu } from '@/types/models'
import { useState } from 'react'
export default function BookingPage() {
return (
<div className="container mx-auto px-4 py-12">
<div className="max-w-3xl mx-auto">
<h1 className="text-4xl font-bold mb-6">Forespørg booking</h1>
<div className="space-y-8">
{/* Step 1: Basic Details */}
<section className="bg-white p-6 rounded-lg shadow">
<h2 className="text-2xl font-semibold mb-4">Vælg dato, sted, kuverter</h2>
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium mb-2">
Hvor?
</label>
<Input
type="text"
placeholder="By eller postnummer"
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Hvornår?
</label>
<DatePicker className="w-full" />
</div>
<div>
<label className="block text-sm font-medium mb-2">
Kuverter
</label>
<NumberInput
min={15}
max={80}
step={1}
className="w-full"
/>
</div>
</div>
</section>
{/* Step 2: Menu & Package Selection */}
<section className="bg-white p-6 rounded-lg shadow">
<h2 className="text-2xl font-semibold mb-4">Vælg menu og pakke</h2>
<div className="grid gap-6">
<div>
<h3 className="font-medium mb-3">Menu</h3>
<div className="grid gap-4">
{sampleData.menus.map((menu: Menu) => (
<label
key={menu.id}
className="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-primary-50"
>
<input
type="radio"
name="menu"
value={menu.id}
className="mt-1"
/>
<div className="ml-3">
<div className="font-medium">{menu.title}</div>
<div className="text-sm text-primary-600">{menu.description}</div>
</div>
</label>
))}
</div>
</div>
<div>
<h3 className="font-medium mb-3">Pakke</h3>
<div className="grid gap-4">
{sampleData.packages.map((pkg: Package) => (
<label
key={pkg.id}
className="flex items-start p-4 border rounded-lg cursor-pointer hover:bg-primary-50"
>
<input
type="radio"
name="package"
value={pkg.id}
className="mt-1"
/>
<div className="ml-3">
<div className="font-medium">{pkg.name}</div>
<div className="text-sm text-primary-600">{pkg.notes}</div>
</div>
</label>
))}
</div>
</div>
</div>
</section>
{/* Step 3: Contact Information */}
<section className="bg-white p-6 rounded-lg shadow">
<h2 className="text-2xl font-semibold mb-4">Kontaktoplysninger</h2>
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium mb-2">
Navn
</label>
<Input type="text" className="w-full" />
</div>
<div>
<label className="block text-sm font-medium mb-2">
Email
</label>
<Input type="email" className="w-full" />
</div>
<div>
<label className="block text-sm font-medium mb-2">
Telefon
</label>
<Input type="tel" className="w-full" />
</div>
<div>
<label className="block text-sm font-medium mb-2">
Besked
</label>
<textarea
rows={4}
className="w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm"
></textarea>
</div>
</div>
</section>
<button
type="submit"
className="w-full bg-accent hover:bg-accent-600 text-white font-medium py-3 px-6 rounded-md transition-colors"
>
Send forespørgsel
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,26 +1,16 @@
@import "tailwindcss";
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

96
src/app/kontakt/page.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { sampleData } from '@/config/sample-data'
import { Input } from '@/components/ui/input'
import { Mail, Phone, Instagram, Facebook } from 'lucide-react'
export default function ContactPage() {
const { contact } = sampleData.chef
return (
<div className="container mx-auto px-4 py-12">
<div className="max-w-2xl mx-auto">
<h1 className="text-4xl font-bold mb-6">Kontakt</h1>
<div className="grid md:grid-cols-2 gap-12">
{/* Contact Info */}
<div>
<h2 className="text-2xl font-semibold mb-6">Kontakt information</h2>
<div className="space-y-4">
<a
href={`tel:${contact.phone}`}
className="flex items-center gap-3 text-lg hover:text-accent"
>
<Phone className="w-5 h-5" />
{contact.phone}
</a>
<a
href={`mailto:${contact.email}`}
className="flex items-center gap-3 text-lg hover:text-accent"
>
<Mail className="w-5 h-5" />
{contact.email}
</a>
<a
href={contact.instagram}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 text-lg hover:text-accent"
>
<Instagram className="w-5 h-5" />
Instagram
</a>
<a
href={contact.facebook}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 text-lg hover:text-accent"
>
<Facebook className="w-5 h-5" />
Facebook
</a>
</div>
</div>
{/* Contact Form */}
<div>
<h2 className="text-2xl font-semibold mb-6">Send besked</h2>
<form className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">
Navn
</label>
<Input type="text" className="w-full" />
</div>
<div>
<label className="block text-sm font-medium mb-2">
Email
</label>
<Input type="email" className="w-full" />
</div>
<div>
<label className="block text-sm font-medium mb-2">
Telefon
</label>
<Input type="tel" className="w-full" />
</div>
<div>
<label className="block text-sm font-medium mb-2">
Besked
</label>
<textarea
rows={4}
className="w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm"
></textarea>
</div>
<button
type="submit"
className="w-full bg-accent hover:bg-accent-600 text-white font-medium py-2 px-4 rounded-md transition-colors"
>
Send besked
</button>
</form>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,34 +1,51 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Metadata } from 'next'
import { siteConfig } from '@/config/site'
import './globals.css'
import { Inter } from 'next/font/google'
import { cn } from '@/lib/utils'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
metadataBase: new URL(siteConfig.domain),
title: {
default: siteConfig.brand.displayName,
template: siteConfig.meta.title,
},
description: siteConfig.meta.description,
robots: {
index: true,
follow: true,
},
openGraph: {
type: 'website',
locale: siteConfig.locale,
url: siteConfig.domain,
title: siteConfig.brand.displayName,
description: siteConfig.meta.description,
siteName: siteConfig.brand.displayName,
},
authors: [
{
name: siteConfig.brand.displayName,
url: siteConfig.domain,
},
],
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<html lang={siteConfig.locale}>
<body className={cn(
inter.className,
"min-h-screen bg-white dark:bg-primary-900"
)}>
{children}
</body>
</html>
);
)
}

174
src/app/menuer/page.tsx Normal file
View File

@@ -0,0 +1,174 @@
'use client'
import { sampleData } from '@/config/sample-data'
import { ImagePlaceholder } from '@/components/ui/image-placeholder'
import { cn } from '@/lib/utils'
import { useState } from 'react'
import { siteConfig } from '@/config/site'
import { Menu } from '@/types/models'
const priceRanges = [
{ label: 'Alle priser', min: 0, max: 1000 },
{ label: 'Under 400 kr', min: 0, max: 400 },
{ label: '400-500 kr', min: 400, max: 500 },
{ label: 'Over 500 kr', min: 500, max: 1000 }
]
const menuTypes = [
'Alle typer',
'Nordisk',
'Vegetarisk',
'Fransk',
'Italiensk',
'Spansk',
'Grill',
'Brunch'
]
export default function MenusPage() {
const [selectedPriceRange, setSelectedPriceRange] = useState(priceRanges[0])
const [selectedType, setSelectedType] = useState(menuTypes[0])
const { menus } = sampleData
const filteredMenus = menus.filter((menu: Menu) => {
const matchesPrice = menu.basePricePerCover >= selectedPriceRange.min &&
menu.basePricePerCover <= selectedPriceRange.max
const matchesType = selectedType === 'Alle typer' ||
menu.tags.includes(selectedType)
return matchesPrice && matchesType
})
return (
<div className="min-h-screen bg-primary-50 pb-12">
<div className="container mx-auto px-4 py-12">
<div className="max-w-6xl mx-auto">
<header className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">Vores menuer</h1>
<p className="text-xl text-primary-600 max-w-2xl mx-auto">
Vælg mellem vores populære menuer eller lad os skræddersy en menu til jeres arrangement.
Alle menuer kan tilpasses efter jeres ønsker og behov.
</p>
</header>
{/* Filters */}
<div className="bg-white p-6 rounded-xl shadow-sm mb-8">
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1">
<label className="block text-sm font-medium mb-2">
Prisklasse
</label>
<select
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2"
onChange={(e) => setSelectedPriceRange(priceRanges[parseInt(e.target.value)])}
>
{priceRanges.map((range, index) => (
<option key={range.label} value={index}>
{range.label}
</option>
))}
</select>
</div>
<div className="flex-1">
<label className="block text-sm font-medium mb-2">
Type
</label>
<select
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2"
onChange={(e) => setSelectedType(e.target.value)}
>
{menuTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
</div>
<div className="mt-4 pt-4 border-t text-sm text-primary-600">
Viser {filteredMenus.length} menuer
{selectedType !== 'Alle typer' && `${selectedType}`}
{selectedPriceRange.label !== 'Alle priser' && `${selectedPriceRange.label}`}
</div>
</div>
{/* Menu Grid */}
<div className="grid gap-8 md:grid-cols-2">
{filteredMenus.map((menu) => (
<div
key={menu.id}
className="bg-white rounded-xl shadow-sm overflow-hidden hover:shadow-lg transition-shadow"
>
<div className="aspect-[4/3] relative bg-primary-100">
<ImagePlaceholder
label={menu.title}
className="absolute inset-0"
/>
</div>
<div className="p-6">
<div className="flex justify-between items-start gap-4 mb-4">
<div>
<h2 className="text-2xl font-semibold mb-2">{menu.title}</h2>
<p className="text-primary-600">{menu.description}</p>
</div>
<div className="text-right shrink-0">
<div className="text-2xl font-bold">
{menu.basePricePerCover} kr
</div>
<div className="text-sm text-primary-500">pr. kuvert</div>
</div>
</div>
<div className="space-y-4">
<div className="bg-primary-50 rounded-lg p-4">
<h3 className="font-medium mb-2">Menuen indeholder</h3>
<ul className="space-y-2 text-primary-600">
{menu.courses.map((course, index) => (
<li key={index} className="flex items-start gap-2">
<span className="font-medium text-xs bg-primary-100 text-primary-700 px-1.5 py-0.5 rounded mt-0.5">
{index === 0 ? 'Forret' : index === menu.courses.length - 1 ? 'Dessert' : 'Hovedret'}
</span>
<span>{course}</span>
</li>
))}
</ul>
</div>
<div className="flex flex-wrap gap-2">
{menu.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 text-sm bg-primary-100 text-primary-700 rounded-full"
>
{tag}
</span>
))}
</div>
<div className="flex items-center justify-between pt-4 border-t">
<div className="text-sm text-primary-500">
Min. {menu.minCovers} kuverter
</div>
<a
href={`/book?menu=${menu.id}`}
className={cn(
"inline-flex items-center justify-center",
"px-4 py-2 rounded-lg",
"bg-accent hover:bg-accent-600",
"text-white font-medium",
"transition-colors"
)}
>
Vælg menu
</a>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}

18
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,18 @@
import Link from 'next/link'
export default function NotFound() {
return (
<div className="min-h-[50vh] flex flex-col items-center justify-center">
<h1 className="text-4xl font-bold mb-4">404</h1>
<p className="text-xl text-primary-600 mb-8">
Siden blev ikke fundet
</p>
<Link
href="/"
className="px-4 py-2 bg-accent text-white rounded-md hover:bg-accent-600 transition-colors"
>
til forsiden
</Link>
</div>
)
}

View File

@@ -1,65 +1,39 @@
import Image from "next/image";
import { Hero } from '@/components/marketing/hero'
import { USPList } from '@/components/marketing/usp-list'
import { FeaturedMenus } from '@/components/marketing/featured-menus'
import { CTASection } from '@/components/marketing/cta-section'
import { Metadata } from 'next'
import { siteConfig } from '@/config/site'
export default function Home() {
export const metadata: Metadata = {
title: siteConfig.brand.displayName + ' | Privat Kok i København',
description: 'Professionel privat kok til din næste fest. Højt service niveau og rustikke gourmét retter tilberedt i dit hjem. Perfekt til selskaber på 2050 personer.',
openGraph: {
title: siteConfig.brand.displayName + ' | Privat Kok i København',
description: 'Professionel privat kok til din næste fest. Højt service niveau og rustikke gourmét retter tilberedt i dit hjem.',
url: siteConfig.domain,
siteName: siteConfig.brand.displayName,
locale: siteConfig.locale,
type: 'website',
},
}
export default function HomePage() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
<div className="flex flex-col min-h-screen">
<main className="flex-grow">
<Hero />
<section className="py-16 bg-primary-50">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold text-center mb-12">
Hvorfor vælge {siteConfig.brand.displayName}?
</h2>
<USPList />
</div>
</section>
<FeaturedMenus />
<CTASection />
</main>
</div>
);
)
}

83
src/app/pakker/page.tsx Normal file
View File

@@ -0,0 +1,83 @@
import { sampleData } from '@/config/sample-data'
import { Package } from '@/types/models'
import { CheckCircle } from 'lucide-react'
function PackageCard({ pkg }: { pkg: Package }) {
return (
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<div className="p-6">
<h3 className="text-2xl font-semibold mb-2">{pkg.name}</h3>
<div className="text-2xl font-bold mb-4">
{pkg.pricePerCover} DKK
<span className="text-sm font-normal text-primary-500 ml-1">
pr. kuvert
</span>
</div>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">Inkluderet</h4>
<ul className="space-y-2">
{pkg.includes.map((item, index) => (
<li key={index} className="flex items-start gap-2">
<CheckCircle className="w-5 h-5 text-accent flex-shrink-0 mt-0.5" />
<span>{item}</span>
</li>
))}
</ul>
</div>
{pkg.upsells.length > 0 && (
<div>
<h4 className="font-medium mb-2">Tilvalg</h4>
<ul className="space-y-2">
{pkg.upsells.map((upsell) => (
<li key={upsell.id} className="flex items-center justify-between">
<span>{upsell.label}</span>
<span className="text-sm text-primary-600">
{upsell.priceFlat ? `${upsell.priceFlat} DKK` :
upsell.pricePerCover ? `${upsell.pricePerCover} DKK pr. kuvert` :
upsell.pricePerHourPerServer ? `${upsell.pricePerHourPerServer} DKK/time` : ''}
</span>
</li>
))}
</ul>
</div>
)}
<div className="text-sm text-primary-600">
<p>{pkg.notes}</p>
</div>
<div>
<a
href={`/book?package=${pkg.id}`}
className="block w-full text-center px-4 py-2 bg-accent hover:bg-accent-600 text-white font-medium rounded-md transition-colors"
>
Vælg pakke
</a>
</div>
</div>
</div>
</div>
)
}
export default function PackagesPage() {
return (
<div className="container mx-auto px-4 py-12">
<div className="max-w-5xl mx-auto">
<h1 className="text-4xl font-bold mb-6">Vælg pakke</h1>
<p className="text-xl text-primary-600 mb-12">
Vælg pakke fra enkel kokkeløsning til fuld bemanding.
</p>
<div className="grid md:grid-cols-2 gap-8">
{sampleData.packages.map((pkg) => (
<PackageCard key={pkg.id} pkg={pkg} />
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,100 @@
import { Users } from 'lucide-react'
import { sampleData } from '@/config/sample-data'
import { BookingRequest } from '@/types/models'
const sampleBookings: BookingRequest[] = [
{
id: 'booking-1',
customer: {
name: 'Anders Andersen',
email: 'anders@example.com',
phone: '+45 12 34 56 78'
},
event: {
type: 'Fødselsdag',
notes: '50 års fødselsdag'
},
covers: 45,
date: '2025-11-15',
location: 'København K',
menuId: 'klassisk-3-retter',
packageId: 'kok-plus',
notes: 'Vegetar-alternativer ønskes til 5 personer',
staffing: {
helpers: 1,
servers: 2
},
quote: {
subtotal: 19125,
ingredients: 2475,
labour: 14000,
helpersCost: 800,
serversCost: 1200,
travelCost: 450,
extras: [
{ id: 'serviceleje', amount: 1500 }
],
margin: 3825,
total: 22950
},
status: 'pending'
}
]
export function BookingsView() {
return (
<div>
<header className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Users className="w-6 h-6" />
Bookinger
</h2>
</header>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-primary-50">
<tr>
<th className="px-4 py-3 text-left">Dato</th>
<th className="px-4 py-3 text-left">Kunde</th>
<th className="px-4 py-3 text-left">Event</th>
<th className="px-4 py-3 text-left">Menu</th>
<th className="px-4 py-3 text-right">Kuverter</th>
<th className="px-4 py-3 text-right">Total</th>
<th className="px-4 py-3 text-right">Status</th>
</tr>
</thead>
<tbody>
{sampleBookings.map((booking) => {
const menu = sampleData.menus.find(m => m.id === booking.menuId)
return (
<tr key={booking.id} className="border-t hover:bg-primary-50/50">
<td className="px-4 py-3">{booking.date}</td>
<td className="px-4 py-3">
<div>
<div>{booking.customer.name}</div>
<div className="text-sm text-primary-500">{booking.location}</div>
</div>
</td>
<td className="px-4 py-3">{booking.event.type}</td>
<td className="px-4 py-3">{menu?.title}</td>
<td className="px-4 py-3 text-right">{booking.covers}</td>
<td className="px-4 py-3 text-right">
{booking.quote.total.toLocaleString('da-DK')} kr.
</td>
<td className="px-4 py-3 text-right">
<span className="px-2 py-1 text-sm bg-yellow-100 text-yellow-800 rounded-full">
Afventer
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { Calendar as CalendarIcon } from 'lucide-react'
export function CalendarView() {
return (
<div>
<header className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold flex items-center gap-2">
<CalendarIcon className="w-6 h-6" />
Kalender
</h2>
</header>
<div className="space-y-4">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="font-medium text-lg mb-4">Kommende events</h3>
<div className="space-y-4">
<div className="flex items-start gap-4 p-4 bg-primary-50 rounded-lg">
<div className="flex-1">
<p className="font-medium">Fredag d. 15. november</p>
<p className="text-primary-600">Klassisk 3-retters 45 kuverter</p>
<p className="text-sm text-primary-500">Anders Andersen København K</p>
</div>
<div>
<span className="px-2 py-1 text-sm bg-yellow-100 text-yellow-800 rounded-full">
Afventer
</span>
</div>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="font-medium text-lg mb-4">Blackout perioder</h3>
<div className="space-y-2">
<p className="text-primary-600">
<strong>Jul:</strong> 24-26. december 2025
</p>
<p className="text-primary-600">
<strong>Nytår:</strong> 31. december 2025
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { Button } from '@/components/ui/button'
export function CTASection() {
return (
<section className="py-20 bg-accent-50">
<div className="container mx-auto px-4 text-center">
<h2 className="text-3xl md:text-4xl font-bold mb-6">
Klar til at planlægge?
</h2>
<Button
variant="accent"
size="lg"
href="/book"
>
Forespørg booking
</Button>
</div>
</section>
)
}

View File

@@ -0,0 +1,123 @@
import { sampleData } from '@/config/sample-data'
import { Menu } from '@/types/models'
import { ImagePlaceholder } from '@/components/ui/image-placeholder'
import { cn } from '@/lib/utils'
import { ArrowRight } from 'lucide-react'
export function FeaturedMenus() {
// Only show the first 4 menus on the marketing page
const { packages, menus } = sampleData
const featuredMenus = menus.slice(0, 4)
return (
<section className="py-16 bg-primary-50">
<div className="container mx-auto px-4">
<div className="max-w-5xl mx-auto text-center mb-12">
<h2 className="text-4xl font-bold mb-4">
Skræddersyet Madoplevelse
</h2>
<p className="text-xl text-primary-600">
Vælg mellem vores populære menuer eller lad os sammensætte en menu der passer til jeres ønsker
</p>
</div>
<div className="grid md:grid-cols-2 gap-8 max-w-5xl mx-auto">
{featuredMenus.map((menu: Menu) => {
const basePackage = packages.find((p: { menuId: string }) => p.menuId === menu.id)
return (
<MenuCard
key={menu.id}
menu={menu}
basePrice={basePackage?.pricePerCover}
/>
)
})}
</div>
<div className="text-center mt-12">
<a
href="/menuer"
className={cn(
"inline-flex items-center justify-center gap-2",
"px-6 py-3 rounded-lg",
"bg-accent hover:bg-accent-600",
"text-white font-medium",
"transition-colors"
)}
>
Se alle menuer
<ArrowRight className="w-4 h-4" />
</a>
</div>
</div>
</section>
)
}
function MenuCard({ menu, basePrice }: { menu: Menu, basePrice?: number }) {
return (
<a
href={`/menuer?menu=${menu.id}`}
className="group relative bg-white rounded-xl shadow-sm overflow-hidden hover:shadow-lg transition-shadow"
>
<div className="aspect-[4/3] relative bg-primary-100">
<ImagePlaceholder
label={menu.title}
className="absolute inset-0"
/>
</div>
<div className="p-6">
<div className="flex justify-between items-start gap-4 mb-4">
<div>
<h3 className="text-2xl font-semibold mb-2 group-hover:text-accent transition-colors">
{menu.title}
</h3>
<p className="text-primary-600">{menu.description}</p>
</div>
<div className="text-right shrink-0">
{basePrice && (
<>
<div className="text-2xl font-bold">
{basePrice} kr
</div>
<div className="text-sm text-primary-500">
pr. kuvert
</div>
</>
)}
</div>
</div>
<div className="space-y-4">
<div className="bg-primary-50 rounded-lg p-4">
<h4 className="font-medium mb-2">Menuen indeholder</h4>
<ul className="space-y-2 text-primary-600">
{menu.courses.slice(0, 3).map((course, index) => (
<li key={index} className="flex items-start gap-2">
<span className="font-medium text-xs bg-primary-100 text-primary-700 px-1.5 py-0.5 rounded mt-0.5">
{index === 0 ? 'Forret' : index === 2 ? 'Dessert' : 'Hovedret'}
</span>
<span>{course}</span>
</li>
))}
{menu.courses.length > 3 && (
<li className="text-sm text-primary-500 mt-2">
+ {menu.courses.length - 3} flere retter
</li>
)}
</ul>
</div>
<div className="flex flex-wrap gap-2">
{menu.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 text-sm bg-primary-100 text-primary-700 rounded-full"
>
{tag}
</span>
))}
</div>
</div>
</div>
</a>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DatePicker } from '@/components/ui/date-picker'
import { NumberInput } from '@/components/ui/number-input'
export function Hero() {
return (
<section className="relative py-20 bg-primary-900 text-white">
<div className="container mx-auto px-4">
<div className="max-w-3xl">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Lej Christian Warme som privat kok
</h1>
<p className="text-xl md:text-2xl text-primary-100 mb-8">
Forret Hovedret Dessert bedst til 2050 kuverter
</p>
<div className="flex gap-4 mb-12">
<Button
variant="accent"
size="lg"
href="/menuer"
>
Se menuer
</Button>
<Button
variant="outline"
size="lg"
href="/book"
>
Forespørg booking
</Button>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-6">
<form className="grid md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-2">
Hvor?
</label>
<Input
type="text"
placeholder="By eller postnummer"
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Hvornår?
</label>
<DatePicker className="w-full" />
</div>
<div>
<label className="block text-sm font-medium mb-2">
Kuverter
</label>
<NumberInput
min={15}
max={80}
step={1}
className="w-full"
/>
</div>
<div className="md:col-span-3">
<Button type="submit" variant="accent" className="w-full md:w-auto">
Tjek dato
</Button>
</div>
</form>
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,61 @@
import { CheckCircle, Users, Clock, UtensilsCrossed, Sparkles, CreditCard } from 'lucide-react'
import { sampleData } from '@/config/sample-data'
const usps = [
{
title: 'Erfaren professionel kok',
description: 'Uddannet kok med fokus på klassisk madlavning og god service',
icon: UtensilsCrossed
},
{
title: 'Perfekt til 20-50 personer',
description: 'Specialiseret i mellemstore selskaber med personligt præg',
icon: Users
},
{
title: 'Faste, gennemsigtige priser',
description: 'Klare priser pr. kuvert - ingen skjulte gebyrer',
icon: CreditCard
},
{
title: 'Alt er inkluderet',
description: 'Indkøb, tilberedning, anretning og oprydning',
icon: CheckCircle
},
{
title: 'Fleksibel planlægning',
description: 'Book i god tid eller find ledige datoer med kort varsel',
icon: Clock
},
{
title: 'Skræddersyet oplevelse',
description: 'Menuer tilpasset dine ønsker og behov',
icon: Sparkles
}
]
export function USPList() {
return (
<div className="max-w-6xl mx-auto">
<ul className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{usps.map((usp, index) => (
<li
key={index}
className="flex flex-col gap-2 p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary-100">
<usp.icon className="w-6 h-6 text-primary-600" />
</div>
<h3 className="font-semibold text-lg">{usp.title}</h3>
</div>
<p className="text-primary-600 ml-11">{usp.description}</p>
</li>
))}
</ul>
<p className="mt-12 text-center text-lg text-primary-600 max-w-2xl mx-auto">
{sampleData.chef.bio}
</p>
</div>
)
}

View File

@@ -0,0 +1,61 @@
import * as React from 'react'
import { VariantProps, cva } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none',
{
variants: {
variant: {
default:
'bg-primary text-white hover:bg-primary-700',
accent:
'bg-accent text-white hover:bg-accent-600',
outline:
'border border-primary bg-transparent hover:bg-primary-50',
ghost:
'bg-transparent hover:bg-primary-50',
},
size: {
default: 'h-10 py-2 px-4',
sm: 'h-9 px-3',
lg: 'h-12 px-8 text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
href?: string
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, href, ...props }, ref) => {
if (href) {
return (
<a
href={href}
className={cn(buttonVariants({ variant, size, className }))}
>
{props.children}
</a>
)
}
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,67 @@
'use client'
import * as React from 'react'
import { format } from 'date-fns'
import { da } from 'date-fns/locale'
import { Calendar as CalendarIcon } from 'lucide-react'
import { DayPicker } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
export interface DatePickerProps {
className?: string
mode?: 'single'
selected?: Date
onSelect?: (date: Date | undefined) => void
disabled?: (date: Date) => boolean
initialFocus?: boolean
}
export function DatePicker({
mode = 'single',
selected,
onSelect,
disabled,
initialFocus,
className,
}: DatePickerProps) {
const [isOpen, setIsOpen] = React.useState(false)
const [selectedDate, setSelectedDate] = React.useState<Date | undefined>(selected)
return (
<div className={cn('relative', className)}>
<Button
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!selectedDate && 'text-muted-foreground'
)}
onClick={() => setIsOpen(true)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedDate ? (
format(selectedDate, 'PPP', { locale: da })
) : (
<span>Vælg en dato</span>
)}
</Button>
{isOpen && (
<div className="absolute top-full mt-2 z-50">
<DayPicker
mode={mode}
selected={selectedDate}
onSelect={(date) => {
setSelectedDate(date)
onSelect?.(date)
setIsOpen(false)
}}
disabled={disabled}
initialFocus={initialFocus}
locale={da}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { cn } from '@/lib/utils'
interface ImagePlaceholderProps {
label: string
className?: string
}
export function ImagePlaceholder({ label, className }: ImagePlaceholderProps) {
return (
<div
className={cn(
"relative w-full h-48 bg-primary-100 dark:bg-primary-800 flex items-center justify-center",
className
)}
>
<span className="text-primary-500 dark:text-primary-400 text-lg font-medium">
{label}
</span>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
type?: 'text' | 'email' | 'password' | 'number' | 'tel'
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-primary-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,67 @@
import * as React from 'react'
import { Input } from './input'
import { Button } from './button'
import { cn } from '@/lib/utils'
export interface NumberInputProps {
min?: number
max?: number
step?: number
value?: number
onChange?: (value: number) => void
className?: string
}
export const NumberInput = React.forwardRef<HTMLDivElement, NumberInputProps>(
({ className, min, max, step = 1, value: propValue, onChange, ...props }, ref) => {
const [value, setValue] = React.useState<number>(propValue ?? min ?? 0)
React.useEffect(() => {
if (propValue !== undefined) {
setValue(propValue)
}
}, [propValue])
const handleChange = (newValue: number) => {
if ((min === undefined || newValue >= min) &&
(max === undefined || newValue <= max)) {
setValue(newValue)
onChange?.(newValue)
}
}
return (
<div ref={ref} className={cn('flex gap-2 items-center', className)}>
<Button
type="button"
variant="outline"
onClick={() => handleChange(value - step)}
>
-
</Button>
<Input
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => {
const newValue = parseInt(e.target.value)
if (!isNaN(newValue)) {
handleChange(newValue)
}
}}
{...props}
/>
<Button
type="button"
variant="outline"
onClick={() => handleChange(value + step)}
>
+
</Button>
</div>
)
}
)
NumberInput.displayName = 'NumberInput'

457
src/config/sample-data.ts Normal file
View File

@@ -0,0 +1,457 @@
export const sampleData = {
rates: {
chefHourly: 350,
helperHourly: 200,
serverHourly: 150,
travelPerKm: 4.5,
minServerHours: 4
},
staffPool: {
helpers: [
{ id: 'h1', name: 'Ekstra køkkenhjælp', maxHours: 8 }
],
servers: [
{ id: 's1', name: 'Servering 1' },
{ id: 's2', name: 'Servering 2' }
],
minHours: 4,
autoAssignRules: {
serveringHoursFunction: 'ceil(covers / 25) * 4',
helpersThreshold: [
{ maxCovers: 35, helpers: 0 },
{ maxCovers: 60, helpers: 1 },
{ maxCovers: 80, helpers: 2 }
]
}
},
chef: {
id: 'christian-warme',
name: 'Christian Warme',
brand: 'warme.dk',
bio: 'Uddannet kok (34 år). Klassiske selskabsmenuer med ro i køkkenet og fokus på gæsternes oplevelse.',
skills: ['Klassisk dansk', 'Nordisk', 'Italiensk', 'Dessert'],
serviceAreaKm: 80,
baseCity: 'København',
photo: '/img/christian-warme.jpg',
contact: {
phone: '+45 12 34 56 78',
email: 'kontakt@warme.dk',
instagram: 'https://www.instagram.com/warme.dk',
facebook: 'https://www.facebook.com/warme.dk'
}
},
menus: [
{
id: 'klassisk-3-retter',
title: 'Klassisk Menu',
description: 'En traditionel dansk menu med et moderne twist. Populært valg til familiefester.',
courses: [
'Varmrøget laks med rygeostcreme, dild og radiser',
'Rosastegt kalvefilet med sæsonens grøntsager og rødvinssauce',
'Chokoladekage med bær og vaniljeis'
],
tags: ['Nordisk', 'Klassisk', 'Populær'],
image: '/img/klassisk.jpg',
baseIngredientsPerCover: 65,
ingredientsCostIndex: 1.0,
baseLabourHours: 4,
incrementalLabourPerCover: 0.05,
minCovers: 15,
leadTimeDays: 7,
basePricePerCover: 295
},
{
id: 'gron-3-retter',
title: 'Grøn Menu',
description: 'Sæsonbaseret vegetarisk menu med fokus på smag og farver. Perfekt til både vegetarer og kødelskere.',
courses: [
'Grillet asparges med hollandaise og karse',
'Svamperisotto med parmesan og urter',
'Pocherede pærer med vaniljeis og hasselnødder'
],
tags: ['Vegetarisk', 'Sæson', 'Populær'],
image: '/img/veg.jpg',
baseIngredientsPerCover: 55,
ingredientsCostIndex: 0.95,
baseLabourHours: 4,
incrementalLabourPerCover: 0.05,
minCovers: 15,
leadTimeDays: 7,
basePricePerCover: 275
},
{
id: 'italiensk-fest',
title: 'Italiensk Aften',
description: 'Det bedste fra det italienske køkken. Afslappet stemning med mad serveret "family style".',
courses: [
'Antipasti med charcuteri, oliven og bruschetta',
'Hjemmelavet pasta med tomat og basilikum',
'Tiramisu med mascarpone'
],
tags: ['Italiensk', 'Pasta', 'Afslappet'],
image: '/img/italiensk.jpg',
baseIngredientsPerCover: 55,
ingredientsCostIndex: 0.9,
baseLabourHours: 4,
incrementalLabourPerCover: 0.05,
minCovers: 15,
leadTimeDays: 7,
basePricePerCover: 250
},
{
id: 'grill-menu',
title: 'Grill Menu',
description: 'Lækker grillmenu perfekt til sommeraftener og uformelle selskaber.',
courses: [
'Grillede peberfrugter med hvidløgsdip',
'Marineret kylling og grillet flanksteak',
'Grillede ferskner med vaniljeis'
],
tags: ['Grill', 'Sommer', 'Afslappet'],
image: '/img/grill.jpg',
baseIngredientsPerCover: 60,
ingredientsCostIndex: 1.0,
baseLabourHours: 4,
incrementalLabourPerCover: 0.05,
minCovers: 20,
leadTimeDays: 7,
basePricePerCover: 275
},
{
id: 'nordisk-buffet',
title: 'Nordisk Buffet',
description: 'Moderne nordisk buffet med sæsonens bedste råvarer. God til større selskaber.',
courses: [
'Koldrøget laks og marinerede sild',
'Frikadeller med nye kartofler',
'Klassisk æblekage med flødeskum'
],
tags: ['Nordisk', 'Buffet', 'Traditionel'],
image: '/img/nordisk.jpg',
baseIngredientsPerCover: 65,
ingredientsCostIndex: 1.0,
baseLabourHours: 5,
incrementalLabourPerCover: 0.04,
minCovers: 25,
leadTimeDays: 7,
basePricePerCover: 250
},
{
id: 'brunch-klassisk',
title: 'Brunch Menu',
description: 'Klassisk brunch buffet med alt det bedste til en god start på dagen.',
courses: [
'Røræg og bacon',
'Croissanter og rundstykker',
'Yoghurt med müsli og frisk frugt'
],
tags: ['Brunch', 'Morgenmad', 'Buffet'],
image: '/img/brunch.jpg',
baseIngredientsPerCover: 45,
ingredientsCostIndex: 0.85,
baseLabourHours: 3,
incrementalLabourPerCover: 0.04,
minCovers: 15,
leadTimeDays: 5,
basePricePerCover: 225
},
{
id: 'tapas-menu',
title: 'Tapas Menu',
description: 'Spanske tapas og pinxtos. Perfekt til reception eller let aftensmad.',
courses: [
'Serrano skinke og manchego',
'Tortilla og patatas bravas',
'Churros med chokolade'
],
tags: ['Spansk', 'Tapas', 'Reception'],
image: '/img/tapas.jpg',
baseIngredientsPerCover: 50,
ingredientsCostIndex: 0.95,
baseLabourHours: 4,
incrementalLabourPerCover: 0.05,
minCovers: 20,
leadTimeDays: 7,
basePricePerCover: 265
},
{
id: 'gourmet-upgrade',
title: 'Gourmet Menu',
description: 'En eksklusiv 4-retters menu til de særlige lejligheder.',
courses: [
'Carpaccio af oksefilet med trøffelolie',
'Jordskokkesuppe med chips',
'Lammeculotte med sæsonens grønt',
'Chokoladefondant med is'
],
tags: ['Gourmet', 'Special', 'Luksus'],
image: '/img/gourmet.jpg',
baseIngredientsPerCover: 95,
ingredientsCostIndex: 1.2,
baseLabourHours: 5,
incrementalLabourPerCover: 0.06,
minCovers: 10,
leadTimeDays: 7,
basePricePerCover: 495
},
{
id: 'nordisk-signatur',
title: 'Nordisk Signatur',
description: 'En gastronomisk rejse gennem Nordens skatte med 4 serveringer.',
courses: [
'Rimmet kammusling med æble og dild',
'Jordskokkesuppe med trøffel og sprødt',
'Grillet torsk med blomkål og brunet smør',
'Brombærdessert med hvid chokolade'
],
tags: ['Nordisk', 'Fisk', 'Sæson', 'Signatur'],
image: '/img/nordisk.jpg',
baseIngredientsPerCover: 95,
ingredientsCostIndex: 1.2,
baseLabourHours: 5,
incrementalLabourPerCover: 0.06,
minCovers: 10,
leadTimeDays: 7,
basePricePerCover: 745
},
{
id: 'italiensk-fest',
title: 'Italiensk Fest',
description: 'Det bedste fra det italienske køkken med 4 serveringer.',
courses: [
'Antipasti med charcuteri og lokale oste',
'Hjemmelavet ravioli med ricotta og salvie',
'Braiseret kalvekød med gremolata',
'Tiramisu med mascarpone'
],
tags: ['Italiensk', 'Pasta', 'Gourmet'],
image: '/img/italiensk.jpg',
baseIngredientsPerCover: 85,
ingredientsCostIndex: 1.1,
baseLabourHours: 5,
incrementalLabourPerCover: 0.05,
minCovers: 10,
leadTimeDays: 7,
basePricePerCover: 695
},
{
id: 'fransk-gourmet',
title: 'Fransk Gourmet',
description: 'Klassisk fransk kogekunst med moderne touch og 5 serveringer.',
courses: [
'Østers med vinaigrette og citron',
'Foie gras med brioche og fignekompot',
'Hummerbisque med cognac',
'Confiteret andelår med pommes sarladaises',
'Crème brûlée med bær'
],
tags: ['Fransk', 'Gourmet', 'Luksus'],
image: '/img/fransk.jpg',
baseIngredientsPerCover: 125,
ingredientsCostIndex: 1.3,
baseLabourHours: 6,
incrementalLabourPerCover: 0.07,
minCovers: 10,
leadTimeDays: 10,
basePricePerCover: 895
},
{
id: 'tapas-deluxe',
title: 'Tapas Deluxe',
description: 'En luksuriøs aften med spanske specialiteter og 7 serveringer.',
courses: [
'Jamon Iberico de Bellota',
'Grillede rejer med hvidløg',
'Blæksprutteragout i egen blæk',
'Patatas bravas med aioli',
'Pimientos de padrón',
'Albóndigas i tomatsauce',
'Churros med chokolade'
],
tags: ['Spansk', 'Tapas', 'Deleretter', 'Luksus'],
image: '/img/tapas.jpg',
baseIngredientsPerCover: 110,
ingredientsCostIndex: 1.2,
baseLabourHours: 5,
incrementalLabourPerCover: 0.06,
minCovers: 10,
leadTimeDays: 7,
basePricePerCover: 745
},
{
id: 'julemenu-luksus',
title: 'Julemenu de Luxe',
description: 'En luksuriøs fortolkning af traditionel julemad med 4 serveringer.',
courses: [
'Hjemmelavet gravad laks med sennepsdressing',
'Julesuppe med and og urter',
'Confiteret andelår med rødkål og brunede kartofler',
'Ris a la mande med kirsebærsauce og sprød mandel'
],
tags: ['Jul', 'Dansk', 'Traditionel', 'Luksus'],
image: '/img/jul.jpg',
baseIngredientsPerCover: 95,
ingredientsCostIndex: 1.15,
baseLabourHours: 5,
incrementalLabourPerCover: 0.06,
minCovers: 10,
leadTimeDays: 14,
basePricePerCover: 695
},
{
id: 'brunch-luksus',
title: 'Luksus Brunch',
description: 'Overdådig brunch med 8 serveringer og bobler.',
courses: [
'Velkomst med mousserende vin og snacks',
'Røræg med trøffel og hjemmebagt brød',
'Røget laks med ærtepuré',
'Charcuteri og nordiske oste',
'Sprøde croissanter og wienerbrød',
'Yoghurt med granola og friske bær',
'Pandekager med ahornsirup',
'Mini-dessertanretning'
],
tags: ['Brunch', 'Morgenmad', 'Luksus'],
image: '/img/brunch.jpg',
baseIngredientsPerCover: 85,
ingredientsCostIndex: 1.1,
baseLabourHours: 4,
incrementalLabourPerCover: 0.05,
minCovers: 10,
leadTimeDays: 5,
basePricePerCover: 545
},
{
id: 'grill-luksus',
title: 'Gourmet Grill Deluxe',
description: 'Eksklusiv grillmenu med 5 serveringer perfekt til sommeraftener.',
courses: [
'Grillede jomfruhummere med urtemayonnaise',
'Wagyu sliders med trøffel',
'Grillet lammecarré med rosmarin',
'Grillede grøntsager med romesco',
'Grillede ferskner med mascarpone og honning'
],
tags: ['Grill', 'Sommer', 'Kød', 'Luksus'],
image: '/img/grill.jpg',
baseIngredientsPerCover: 120,
ingredientsCostIndex: 1.3,
baseLabourHours: 5,
incrementalLabourPerCover: 0.06,
minCovers: 10,
leadTimeDays: 7,
basePricePerCover: 845
},
{
id: 'sushi-signatur',
title: 'Signatur Sushi',
description: 'Eksklusiv omakase-inspireret sushimenu med 6 serveringer.',
courses: [
'Toro nigiri med trøffel',
'Laks tataki med ponzu',
'Kammusling nigiri med yuzu',
'Tempura rejer med wasabi-mayo',
'Udvalg af maki rolls',
'Matcha is med hvid chokolade'
],
tags: ['Japansk', 'Sushi', 'Fisk', 'Luksus'],
image: '/img/sushi.jpg',
baseIngredientsPerCover: 130,
ingredientsCostIndex: 1.4,
baseLabourHours: 6,
incrementalLabourPerCover: 0.07,
minCovers: 10,
leadTimeDays: 7,
basePricePerCover: 895
},
{
id: 'fusion-menu',
title: 'Asian Fusion',
description: 'Moderne asiatisk fusion menu med 5 serveringer.',
courses: [
'Tuna tartare med wasabi og sesam',
'Dumplings med and og hoisin',
'Thai inspireret suppe med kokos',
'Korean BBQ short ribs',
'Yuzu pannacotta med mango'
],
tags: ['Asiatisk', 'Fusion', 'Modern'],
image: '/img/fusion.jpg',
baseIngredientsPerCover: 95,
ingredientsCostIndex: 1.2,
baseLabourHours: 5,
incrementalLabourPerCover: 0.06,
minCovers: 10,
leadTimeDays: 7,
basePricePerCover: 745
}
],
packages: [
{
id: "basis",
name: "Basis Pakke",
menuId: "klassisk-3-retter",
pricePerCover: 295,
minCovers: 15,
maxCovers: 30,
includes: [
"Menu planlægning",
"Indkøb af råvarer",
"Madlavning på lokation",
"Let oprydning i køkken"
],
upsells: [
{ id: "anretning", label: "Tallerkenanretning", priceFlat: 500 },
{ id: "kaffe", label: "Kaffe/te", pricePerCover: 25 }
],
notes: "God basis løsning til familiefester. Maden serveres på fade, hvor gæsterne selv tager."
},
{
id: "service",
name: "Service Pakke",
menuId: "klassisk-3-retter",
pricePerCover: 395,
minCovers: 20,
maxCovers: 50,
includes: [
"Menu planlægning",
"Indkøb af råvarer",
"Madlavning på lokation",
"Tallerkenanretning",
"Professionel servering",
"Fuld oprydning"
],
upsells: [
{ id: "velkomst", label: "Velkomstdrink & snacks", pricePerCover: 45 },
{ id: "vinmenu", label: "Vinmenu (3 glas)", pricePerCover: 225 },
{ id: "kaffe", label: "Kaffe/te med sødt", pricePerCover: 35 }
],
notes: "Komplet løsning med personlig service og professionel servering."
},
{
id: "gourmet",
name: "Gourmet Pakke",
menuId: "gourmet-upgrade",
pricePerCover: 595,
minCovers: 10,
maxCovers: 30,
includes: [
"Menu planlægning",
"Indkøb af kvalitetsråvarer",
"Madlavning på lokation",
"Tallerkenanretning",
"Professionel servering",
"Vinmenu (3 glas)",
"Kaffe med sødt",
"Fuld oprydning"
],
upsells: [
{ id: "champagne", label: "Champagne velkomst", pricePerCover: 95 },
{ id: "ekstravin", label: "Ekstra glas vin", pricePerCover: 75 },
{ id: "natmad", label: "Let natmad", pricePerCover: 75 }
],
notes: "Eksklusiv løsning til de særlige anledninger med gourmetmenu og vinmenu inkluderet."
}
],
} as const

27
src/config/site.ts Normal file
View File

@@ -0,0 +1,27 @@
export const siteConfig = {
name: "warme",
domain: "https://warme.dk",
locale: "da-DK",
currency: "DKK",
brand: {
displayName: "Christian Warme Privat kok",
tagline: "God mad. Nærvær. Ingen stress.",
primaryColor: "#1f2937",
accentColor: "#f59e0b",
logoAlt: "warme.dk logo"
},
meta: {
title: "%s | warme.dk",
description: "Lej Christian Warme som privat kok til fødselsdag, konfirmation og firmaevents. Gennemsigtige kuvertpriser og fleksibel bemanding i Storkøbenhavn.",
},
analytics: {
pixels: ["Meta", "GA4"],
events: [
"SearchPerformed",
"MenuViewed",
"PackageSelected",
"QuoteCalculated",
"BookingRequestSubmitted"
]
}
} as const

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

111
src/types/models.ts Normal file
View File

@@ -0,0 +1,111 @@
export interface Chef {
id: string
name: string
brand: string
bio: string
skills: string[]
serviceAreaKm: number
baseCity: string
photo: string
availabilityRules: {
leadTimeDays: number
maxCoversSolo: number
maxCoversWithHelper: number
absoluteMaxCovers: number
bufferHoursPre: number
bufferHoursPost: number
}
blackoutDates: string[]
workShiftsIntegration: {
type: 'ical_or_csv'
fields: string[]
blockingRule: string
}
contact: {
phone: string
email: string
instagram: string
facebook: string
}
}
export interface Menu {
id: string
title: string
description: string
courses: readonly string[]
tags: readonly string[]
image: string
baseIngredientsPerCover: number
ingredientsCostIndex: number
baseLabourHours: number
incrementalLabourPerCover: number
minCovers: number
leadTimeDays: number
basePricePerCover: number
}
export interface Package {
id: string
name: string
menuId: string
pricePerCover: number
minCovers: number
maxCovers: number
includes: readonly string[]
upsells: ReadonlyArray<{
id: string
label: string
priceFlat?: number
pricePerCover?: number
pricePerHourPerServer?: number
minHours?: number
}>
notes: string
}
export interface BookingRequest {
id: string
customer: {
name: string
email: string
phone: string
}
event: {
type: string
notes: string
}
covers: number
date: string
location: string
menuId: string
packageId: string
notes: string
staffing: {
helpers: number
servers: number
}
quote: {
subtotal: number
ingredients: number
labour: number
helpersCost: number
serversCost: number
travelCost: number
extras: Array<{
id: string
amount: number
}>
margin: number
total: number
}
status: 'pending' | 'confirmed' | 'cancelled'
}
export interface Rates {
chefHourly: number
helperHourly: number
serverHourly: number
travelPerKm: number
minServerHours: number
}

41
tailwind.config.js Normal file
View File

@@ -0,0 +1,41 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#1f2937',
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
accent: {
DEFAULT: '#f59e0b',
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
},
},
},
plugins: [],
}

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsx": "preserve",
"incremental": true,
"plugins": [
{
@@ -19,7 +23,9 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": [
@@ -30,5 +36,7 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": [
"node_modules"
]
}