Initial commit - Warme catering website
28
ecosystem.config.js
Normal 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
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'next/core-web-vitals',
|
||||
],
|
||||
rules: {
|
||||
'@next/next/no-img-element': 'off',
|
||||
},
|
||||
}
|
||||
31
next.config.mjs
Normal 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;
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
3674
package-lock.json
generated
44
package.json
@@ -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
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'tailwindcss': {},
|
||||
'autoprefixer': {},
|
||||
}
|
||||
}
|
||||
BIN
public/img/brunch.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/img/fransk.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/img/grill.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/img/italiensk.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/img/jul.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/img/klassisk.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/img/nordisk.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/img/tapas.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/img/veg.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
@@ -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 |
@@ -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
@@ -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
@@ -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}`)
|
||||
})
|
||||
})
|
||||
154
src/app/(admin)/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
src/app/(marketing)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
src/app/(marketing)/page.tsx
Normal 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å 20–50 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>
|
||||
)
|
||||
}
|
||||
31
src/app/betingelser/page.tsx
Normal 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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
>
|
||||
Gå til forsiden
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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å 20–50 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
@@ -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>
|
||||
)
|
||||
}
|
||||
100
src/components/admin/bookings-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
src/components/admin/calendar-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
src/components/marketing/cta-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
src/components/marketing/featured-menus.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
src/components/marketing/hero.tsx
Normal 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 20–50 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>
|
||||
)
|
||||
}
|
||||
61
src/components/marketing/usp-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
src/components/ui/button.tsx
Normal 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 }
|
||||
67
src/components/ui/date-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
src/components/ui/image-placeholder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/components/ui/input.tsx
Normal 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 }
|
||||
67
src/components/ui/number-input.tsx
Normal 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
@@ -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 (3–4 å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
@@ -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
@@ -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
@@ -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
@@ -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: [],
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||