diff --git a/SAVE_CHEF_IMAGE.md b/SAVE_CHEF_IMAGE.md
new file mode 100644
index 0000000..9c932a5
--- /dev/null
+++ b/SAVE_CHEF_IMAGE.md
@@ -0,0 +1,29 @@
+# Save Chef Profile Image
+
+## Quick Instructions
+
+The chef's profile image has been uploaded to the conversation.
+
+**Save it as:** `/home/alex/git/warme/public/img/christian-warme.jpg`
+
+### Manual Steps:
+
+1. Right-click the image in the conversation
+2. Save as: `christian-warme.jpg`
+3. Move to: `/home/alex/git/warme/public/img/`
+
+OR use this command after downloading:
+
+```bash
+cp ~/Downloads/christian-warme.jpg /home/alex/git/warme/public/img/
+```
+
+### Then rebuild:
+
+```bash
+cd /home/alex/git/warme
+npm run build
+pm2 restart warme
+```
+
+The profile page at `/om-christian` is already configured to use this image!
diff --git a/UPLOAD_IMAGE.md b/UPLOAD_IMAGE.md
new file mode 100644
index 0000000..23ec380
--- /dev/null
+++ b/UPLOAD_IMAGE.md
@@ -0,0 +1,50 @@
+# Upload Christian's Profile Image
+
+## Instructions
+
+To add Christian's profile image to the website:
+
+1. Save the chef's portrait image as: `public/img/christian-warme.jpg`
+
+2. Then uncomment the image code in: `src/app/om-christian/page.tsx`
+
+ Replace this:
+ ```tsx
+ {/* Placeholder - Replace with actual image */}
+
+
+
+ {/* Uncomment when image is uploaded:
+
+ */}
+ ```
+
+ With this:
+ ```tsx
+
+ ```
+
+3. Rebuild and restart:
+ ```bash
+ npm run build
+ pm2 restart warme
+ ```
+
+## Image Requirements
+
+- Format: JPG or PNG
+- Recommended size: 800x800px minimum
+- Aspect ratio: Square (1:1)
+- File size: Keep under 500KB for optimal performance
diff --git a/cache-debug-test.spec.ts b/cache-debug-test.spec.ts
new file mode 100644
index 0000000..f76039d
--- /dev/null
+++ b/cache-debug-test.spec.ts
@@ -0,0 +1,54 @@
+import { test } from '@playwright/test';
+
+test('Debug browser cache vs server response', async ({ page }) => {
+ // Completely clear cache and storage
+ await page.context().clearCookies();
+
+ // Set basic auth header
+ await page.setExtraHTTPHeaders({
+ 'Authorization': 'Basic ' + Buffer.from('brotha:makefood1').toString('base64'),
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ 'Pragma': 'no-cache',
+ 'Expires': '0'
+ });
+
+ console.log('1. Going to page with cache disabled...');
+ await page.goto('http://localhost:3000/admin/availability', {
+ waitUntil: 'networkidle'
+ });
+
+ await page.waitForSelector('.cook-calendar', { timeout: 10000 });
+ await page.waitForTimeout(3000);
+
+ // Check what we get immediately
+ const headers1 = await page.locator('.cook-calendar thead th').allTextContents();
+ const firstRow1 = await page.locator('.cook-calendar tbody tr:first-child td button').allTextContents();
+
+ console.log('2. Initial load:');
+ console.log(' Headers:', headers1.join(' '));
+ console.log(' First row:', firstRow1.join(' '));
+
+ // Force another reload with cache busting
+ console.log('3. Hard reload...');
+ await page.reload({ waitUntil: 'networkidle' });
+ await page.waitForTimeout(2000);
+
+ const headers2 = await page.locator('.cook-calendar thead th').allTextContents();
+ const firstRow2 = await page.locator('.cook-calendar tbody tr:first-child td button').allTextContents();
+
+ console.log('4. After reload:');
+ console.log(' Headers:', headers2.join(' '));
+ console.log(' First row:', firstRow2.join(' '));
+
+ // Check if there's a difference between SSR and client hydration
+ const pageSource = await page.content();
+ if (pageSource.includes('27 {
+ // Set basic auth header
+ await page.setExtraHTTPHeaders({
+ 'Authorization': 'Basic ' + Buffer.from('brotha:makefood1').toString('base64')
+ });
+
+ await page.goto('http://localhost:3000/admin/availability');
+ await page.waitForSelector('.cook-calendar');
+
+ // Tjek månedsnavn
+ const monthHeader = await page.locator('text=november 2025');
+ await expect(monthHeader).toBeVisible();
+ console.log('✅ Month header shows November 2025');
+
+ // Find alle dage i kalenderen
+ const dayElements = await page.locator('.cook-calendar .rdp-day').all();
+ console.log(`Found ${dayElements.length} day elements`);
+
+ // Extract dag numre og deres position
+ const dayData = [];
+ for (let i = 0; i < dayElements.length; i++) {
+ const dayText = await dayElements[i].textContent();
+ const classes = await dayElements[i].getAttribute('class');
+ const isOutside = classes?.includes('rdp-day_outside') || false;
+
+ dayData.push({
+ index: i,
+ text: dayText?.trim(),
+ isOutside: isOutside
+ });
+ }
+
+ console.log('Calendar days:', dayData.slice(0, 10)); // First 10 days
+
+ // Find første dag i november (1)
+ const firstNovemberDay = dayData.find(day => day.text === '1' && !day.isOutside);
+ if (firstNovemberDay) {
+ console.log(`✅ First day of November (1) found at position ${firstNovemberDay.index}`);
+
+ // November 1, 2025 er en lørdag (weekday 6, men vi starter ugen på mandag så position 5)
+ // Så 1. november skulle være på position 5 (if we count from 0: Ma=0, Ti=1, On=2, To=3, Fr=4, Lø=5)
+ const expectedPosition = 5; // Saturday in week starting Monday
+ const actualPosition = firstNovemberDay.index % 7;
+
+ console.log(`November 1st is at grid position ${actualPosition}, expected ${expectedPosition}`);
+
+ if (actualPosition === expectedPosition) {
+ console.log('✅ November 1st is correctly positioned on Saturday');
+ } else {
+ console.log(`❌ November 1st position is wrong. Expected ${expectedPosition}, got ${actualPosition}`);
+ }
+ } else {
+ console.log('❌ Could not find November 1st');
+ }
+
+ // Find 5. november (today)
+ const fifthNovemberDay = dayData.find(day => day.text === '5' && !day.isOutside);
+ if (fifthNovemberDay) {
+ console.log(`✅ November 5th (today) found at position ${fifthNovemberDay.index}`);
+
+ // November 5, 2025 er en onsdag (position 2 if week starts Monday)
+ const expectedPosition = 2; // Wednesday
+ const actualPosition = fifthNovemberDay.index % 7;
+
+ console.log(`November 5th is at grid position ${actualPosition}, expected ${expectedPosition}`);
+
+ if (actualPosition === expectedPosition) {
+ console.log('✅ November 5th is correctly positioned on Wednesday');
+ } else {
+ console.log(`❌ November 5th position is wrong. Expected ${expectedPosition}, got ${actualPosition}`);
+ }
+ }
+
+ // Tjek at vi har 30 dage i november
+ const novemberDays = dayData.filter(day => !day.isOutside);
+ console.log(`November has ${novemberDays.length} days visible`);
+
+ // Vis de første par uger for debugging
+ console.log('\nFirst two weeks layout:');
+ for (let week = 0; week < 2; week++) {
+ const weekDays = dayData.slice(week * 7, (week + 1) * 7);
+ const weekString = weekDays.map(day =>
+ (day.isOutside ? `(${day.text})` : day.text || 'X').padStart(4, ' ')
+ ).join(' ');
+ console.log(`Week ${week + 1}: ${weekString}`);
+ }
+});
\ No newline at end of file
diff --git a/calendar-debug.png b/calendar-debug.png
new file mode 100644
index 0000000..754c0fa
Binary files /dev/null and b/calendar-debug.png differ
diff --git a/calendar-screenshot.spec.ts b/calendar-screenshot.spec.ts
new file mode 100644
index 0000000..3a0229c
--- /dev/null
+++ b/calendar-screenshot.spec.ts
@@ -0,0 +1,27 @@
+import { test } from '@playwright/test';
+
+test('Take screenshot of calendar for visual inspection', async ({ page }) => {
+ // Set basic auth header
+ await page.setExtraHTTPHeaders({
+ 'Authorization': 'Basic ' + Buffer.from('brotha:makefood1').toString('base64')
+ });
+
+ await page.goto('http://localhost:3000/admin/availability');
+ await page.waitForSelector('.cook-calendar');
+
+ // Wait a bit more to ensure everything is loaded
+ await page.waitForTimeout(2000);
+
+ // Take screenshot of just the calendar
+ const calendar = page.locator('.cook-calendar');
+ await calendar.screenshot({ path: 'calendar-debug.png' });
+
+ // Also log the actual HTML structure
+ const calendarHTML = await calendar.innerHTML();
+ console.log('=== FULL CALENDAR HTML ===');
+ console.log(calendarHTML);
+
+ // Check actual header text
+ const headers = await page.locator('.cook-calendar th, .cook-calendar .rdp-head_cell').allTextContents();
+ console.log('Week headers:', headers);
+});
\ No newline at end of file
diff --git a/calendar-test.spec.ts b/calendar-test.spec.ts
new file mode 100644
index 0000000..4c9e575
--- /dev/null
+++ b/calendar-test.spec.ts
@@ -0,0 +1,138 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Admin Availability Calendar', () => {
+ test.beforeEach(async ({ page }) => {
+ // Set basic auth header
+ await page.setExtraHTTPHeaders({
+ 'Authorization': 'Basic ' + Buffer.from('brotha:makefood1').toString('base64')
+ });
+
+ // Gå til admin availability siden
+ await page.goto('http://localhost:3000/admin/availability');
+
+ // Vent på siden loader
+ await page.waitForSelector('.cook-calendar');
+ });
+
+ test('should display calendar with proper layout', async ({ page }) => {
+ // Tjek at kalenderen vises
+ const calendar = await page.locator('.cook-calendar');
+ await expect(calendar).toBeVisible();
+
+ // Tjek at månedsnavn vises
+ const monthHeader = await page.locator('text=november 2025');
+ await expect(monthHeader).toBeVisible();
+
+ // Tjek at weekday headers vises
+ const weekdays = await page.locator('[class*="rdp"]');
+ await expect(weekdays.first()).toBeVisible();
+
+ console.log('✅ Calendar layout looks good');
+ });
+
+ test('should have proper day styling', async ({ page }) => {
+ // Find en dag i kalenderen
+ const dayButtons = await page.locator('.cook-calendar .rdp-day');
+ const firstDay = dayButtons.first();
+
+ // Tjek at dagen er synlig
+ await expect(firstDay).toBeVisible();
+
+ // Tjek background farve (should be green for available or red for blocked)
+ const styles = await firstDay.evaluate((el) => {
+ const computed = window.getComputedStyle(el);
+ return {
+ backgroundColor: computed.backgroundColor,
+ color: computed.color,
+ border: computed.border
+ };
+ });
+
+ console.log('Day styling:', styles);
+
+ // Tjek at der er enten grøn eller rød background
+ const isGreenOrRed = styles.backgroundColor.includes('240, 253, 244') || // green-50
+ styles.backgroundColor.includes('220, 38, 38'); // red-600
+
+ expect(isGreenOrRed).toBeTruthy();
+ console.log('✅ Day colors are correct');
+ });
+
+ test('should handle day clicks', async ({ page }) => {
+ // Find en klikkbar dag
+ const dayButtons = await page.locator('.cook-calendar .rdp-day');
+
+ // Vent på at dage er loaded
+ await expect(dayButtons.first()).toBeVisible();
+
+ // Klik på første dag
+ await dayButtons.first().click();
+
+ // Vent kort på API response
+ await page.waitForTimeout(1000);
+
+ // Tjek at der er en toast besked eller loading state
+ const possibleMessages = [
+ page.locator('text=✅'),
+ page.locator('text=🚫'),
+ page.locator('text=Gemmer ændring'),
+ page.locator('text=Der skete en fejl')
+ ];
+
+ let messageFound = false;
+ for (const message of possibleMessages) {
+ if (await message.isVisible()) {
+ messageFound = true;
+ console.log('✅ Click interaction working - message found');
+ break;
+ }
+ }
+
+ // Hvis ingen besked, så tjek om API kaldet skete via network requests
+ if (!messageFound) {
+ console.log('⚠️ No visible message, but click may have worked');
+ }
+ });
+
+ test('should fit within container', async ({ page }) => {
+ // Tjek calendar container størrelse
+ const calendar = await page.locator('.cook-calendar');
+ const calendarBox = await calendar.boundingBox();
+
+ // Tjek container
+ const container = await page.locator('div.max-w-md');
+ const containerBox = await container.boundingBox();
+
+ if (calendarBox && containerBox) {
+ // Kalenderen skal passe inden i containeren
+ expect(calendarBox.width).toBeLessThanOrEqual(containerBox.width + 20); // 20px tolerance
+ console.log(`✅ Calendar fits: ${calendarBox.width}px ≤ ${containerBox.width}px`);
+ }
+ });
+
+ test('should show correct blocked dates', async ({ page }) => {
+ // Tjek API data
+ const response = await page.request.get('http://localhost:3000/api/availability/blocked');
+ const blockedDates = await response.json();
+
+ console.log('Blocked dates from API:', blockedDates.length);
+
+ if (blockedDates.length > 0) {
+ // Find røde dage i kalenderen
+ const redDays = await page.locator('.cook-calendar .rdp-day').evaluateAll((buttons) => {
+ return buttons.filter(btn => {
+ const styles = window.getComputedStyle(btn);
+ return styles.backgroundColor.includes('220, 38, 38'); // red-600
+ }).length;
+ });
+
+ console.log(`Found ${redDays} red days in calendar for ${blockedDates.length} blocked dates`);
+
+ // Der skal være mindst nogle røde dage hvis der er blokerede datoer
+ if (blockedDates.length > 0) {
+ expect(redDays).toBeGreaterThan(0);
+ console.log('✅ Blocked dates show as red');
+ }
+ }
+ });
+});
\ No newline at end of file
diff --git a/cookie-test.spec.ts b/cookie-test.spec.ts
new file mode 100644
index 0000000..e535619
--- /dev/null
+++ b/cookie-test.spec.ts
@@ -0,0 +1,39 @@
+import { test } from '@playwright/test';
+
+test('Check cookie and redirect after login', async ({ page }) => {
+ // Go to login page
+ await page.goto('http://localhost:3000/login');
+
+ // Fill and submit form
+ await page.fill('#username', 'brotha');
+ await page.fill('#password', 'makefood1');
+ await page.click('button[type="submit"]');
+
+ // Wait for response
+ await page.waitForTimeout(2000);
+
+ // Check cookies
+ const cookies = await page.context().cookies();
+ const authCookie = cookies.find(c => c.name === 'authToken');
+
+ console.log('Auth cookie:', authCookie ? 'Present' : 'Missing');
+ if (authCookie) {
+ console.log('Cookie value length:', authCookie.value.length);
+ console.log('Cookie secure:', authCookie.secure);
+ console.log('Cookie httpOnly:', authCookie.httpOnly);
+ }
+
+ // Try to navigate to admin manually
+ console.log('Trying to navigate to /admin...');
+ await page.goto('http://localhost:3000/admin');
+ await page.waitForTimeout(1000);
+
+ const finalUrl = page.url();
+ console.log('Final URL:', finalUrl);
+
+ if (finalUrl.includes('/login')) {
+ console.log('❌ Redirected back to login - middleware issue');
+ } else if (finalUrl.includes('/admin')) {
+ console.log('✅ Successfully accessed admin');
+ }
+});
\ No newline at end of file
diff --git a/current-calendar.png b/current-calendar.png
new file mode 100644
index 0000000..ca29dec
Binary files /dev/null and b/current-calendar.png differ
diff --git a/debug-calendar.spec.ts b/debug-calendar.spec.ts
new file mode 100644
index 0000000..4111fd0
--- /dev/null
+++ b/debug-calendar.spec.ts
@@ -0,0 +1,41 @@
+import { test } from '@playwright/test';
+
+test('Debug calendar DOM structure', async ({ page }) => {
+ // Set basic auth header
+ await page.setExtraHTTPHeaders({
+ 'Authorization': 'Basic ' + Buffer.from('brotha:makefood1').toString('base64')
+ });
+
+ await page.goto('http://localhost:3000/admin/availability');
+ await page.waitForSelector('.cook-calendar');
+
+ // Log all classes i kalenderen
+ const calendarHTML = await page.locator('.cook-calendar').innerHTML();
+ console.log('Calendar HTML structure:');
+ console.log(calendarHTML.slice(0, 1000) + '...');
+
+ // Find alle elements med rdp classes
+ const rdpElements = await page.locator('[class*="rdp"]').all();
+ console.log(`Found ${rdpElements.length} elements with rdp classes`);
+
+ for (let i = 0; i < Math.min(10, rdpElements.length); i++) {
+ const className = await rdpElements[i].getAttribute('class');
+ const tagName = await rdpElements[i].evaluate(el => el.tagName);
+ console.log(`${i}: ${tagName}.${className}`);
+ }
+
+ // Tjek computed styles på nogle elements
+ const dayElements = await page.locator('.cook-calendar [class*="day"]').all();
+ console.log(`Found ${dayElements.length} day elements`);
+
+ if (dayElements.length > 0) {
+ const firstDayStyle = await dayElements[0].evaluate((el) => {
+ const computed = window.getComputedStyle(el);
+ return {
+ backgroundColor: computed.backgroundColor,
+ className: el.className
+ };
+ });
+ console.log('First day style:', firstDayStyle);
+ }
+});
\ No newline at end of file
diff --git a/debug-login.spec.ts b/debug-login.spec.ts
new file mode 100644
index 0000000..245e36f
--- /dev/null
+++ b/debug-login.spec.ts
@@ -0,0 +1,40 @@
+import { test } from '@playwright/test';
+
+test('Debug login form behavior', async ({ page }) => {
+ // Capture console messages
+ const messages: string[] = [];
+ page.on('console', msg => {
+ messages.push(`${msg.type()}: ${msg.text()}`);
+ });
+
+ // Go to login page
+ await page.goto('http://localhost:3000/login');
+
+ // Fill form
+ await page.fill('#username', 'brotha');
+ await page.fill('#password', 'makefood1');
+
+ // Submit form
+ await page.click('button[type="submit"]');
+
+ // Wait a bit
+ await page.waitForTimeout(3000);
+
+ // Check current URL
+ const currentUrl = page.url();
+ console.log('Current URL after login:', currentUrl);
+
+ // Print console messages
+ console.log('Console messages:');
+ messages.forEach(msg => console.log(' ', msg));
+
+ // Check if there are any error messages on page
+ const errorMessage = await page.locator('[class*="red"]').textContent();
+ if (errorMessage) {
+ console.log('Error message on page:', errorMessage);
+ }
+
+ // Check localStorage
+ const authToken = await page.evaluate(() => localStorage.getItem('authToken'));
+ console.log('Auth token in localStorage:', authToken ? 'Present' : 'Missing');
+});
\ No newline at end of file
diff --git a/final-calendar-debug.png b/final-calendar-debug.png
new file mode 100644
index 0000000..d5dbe93
Binary files /dev/null and b/final-calendar-debug.png differ
diff --git a/force-refresh-test.spec.ts b/force-refresh-test.spec.ts
new file mode 100644
index 0000000..4e2d935
--- /dev/null
+++ b/force-refresh-test.spec.ts
@@ -0,0 +1,40 @@
+import { test } from '@playwright/test';
+
+test('Force browser refresh and check cache', async ({ page }) => {
+ // Set basic auth header
+ await page.setExtraHTTPHeaders({
+ 'Authorization': 'Basic ' + Buffer.from('brotha:makefood1').toString('base64')
+ });
+
+ // Force hard reload
+ await page.goto('http://localhost:3000/admin/availability', {
+ waitUntil: 'networkidle'
+ });
+
+ // Bypass cache
+ await page.reload({ waitUntil: 'networkidle' });
+
+ await page.waitForSelector('.cook-calendar');
+ await page.waitForTimeout(3000);
+
+ // Take screenshot
+ await page.screenshot({ path: 'current-calendar.png', fullPage: true });
+
+ // Check if there are any console errors
+ const logs = [];
+ page.on('console', msg => {
+ logs.push(`${msg.type()}: ${msg.text()}`);
+ });
+
+ // Reload one more time and check
+ await page.reload({ waitUntil: 'networkidle' });
+ await page.waitForTimeout(2000);
+
+ const tableHTML = await page.locator('.cook-calendar table').innerHTML();
+ console.log('Table HTML (first 500 chars):');
+ console.log(tableHTML.substring(0, 500) + '...');
+
+ // Check actual layout one more time
+ const firstRowCells = await page.locator('.cook-calendar tbody tr:first-child td button').allTextContents();
+ console.log('After hard refresh - First row:', firstRowCells);
+});
\ No newline at end of file
diff --git a/logs/err-0.log b/logs/err-0.log
index fe985c1..f99d502 100644
--- a/logs/err-0.log
+++ b/logs/err-0.log
@@ -64,3 +64,45 @@ Error: Could not find a production build in the '.next' directory. Try building
at async cacheEntry.imageResponseCache.get.incrementalCache (/home/alex/git/warme/node_modules/next/dist/server/next-server.js:182:65)
at async /home/alex/git/warme/node_modules/next/dist/server/response-cache/index.js:90:36
at async /home/alex/git/warme/node_modules/next/dist/lib/batcher.js:45:32
+2025-11-07T09:40:47: ⨯ TypeError: Cannot read properties of undefined (reading 'bind')
+ at NextNodeServer.handleRequestImpl (/home/alex/git/warme/node_modules/next/dist/server/base-server.js:476:50)
+ at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
+2025-11-07T09:40:49: ⨯ TypeError: Cannot read properties of undefined (reading 'bind')
+ at NextNodeServer.handleRequestImpl (/home/alex/git/warme/node_modules/next/dist/server/base-server.js:476:50)
+ at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
+2025-11-07T09:40:53: ⨯ TypeError: Cannot read properties of undefined (reading 'bind')
+ at NextNodeServer.handleRequestImpl (/home/alex/git/warme/node_modules/next/dist/server/base-server.js:476:50)
+ at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
+2025-11-07T09:41:01: ⨯ TypeError: Cannot read properties of undefined (reading 'bind')
+ at NextNodeServer.handleRequestImpl (/home/alex/git/warme/node_modules/next/dist/server/base-server.js:476:50)
+ at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
+2025-11-07T09:41:17: ⨯ TypeError: Cannot read properties of undefined (reading 'bind')
+ at NextNodeServer.handleRequestImpl (/home/alex/git/warme/node_modules/next/dist/server/base-server.js:476:50)
+ at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
+2025-11-07T09:41:51: ⨯ TypeError: Cannot read properties of undefined (reading 'bind')
+ at NextNodeServer.handleRequestImpl (/home/alex/git/warme/node_modules/next/dist/server/base-server.js:476:50)
+ at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
+2025-11-07T09:42:55: ⨯ TypeError: Cannot read properties of undefined (reading 'bind')
+ at NextNodeServer.handleRequestImpl (/home/alex/git/warme/node_modules/next/dist/server/base-server.js:476:50)
+ at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
+2025-11-07T09:45:02: ⨯ TypeError: Cannot read properties of undefined (reading 'bind')
+ at NextNodeServer.handleRequestImpl (/home/alex/git/warme/node_modules/next/dist/server/base-server.js:476:50)
+ at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
+2025-11-07T09:45:04: ⨯ TypeError: Cannot read properties of undefined (reading 'bind')
+ at NextNodeServer.handleRequestImpl (/home/alex/git/warme/node_modules/next/dist/server/base-server.js:476:50)
+ at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
+2025-11-07T10:48:04: ⨯ TypeError: Cannot read properties of undefined (reading 'clientModules')
+ at /home/alex/git/warme/node_modules/next/dist/compiled/next-server/app-page.runtime.prod.js:16:24425
+ at /home/alex/git/warme/node_modules/next/dist/server/lib/trace/tracer.js:191:62
+ at /home/alex/git/warme/node_modules/next/dist/server/lib/trace/tracer.js:140:36
+ at NoopContextManager.with (/home/alex/git/warme/node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:7062)
+ at ContextAPI.with (/home/alex/git/warme/node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:518)
+ at NoopTracer.startActiveSpan (/home/alex/git/warme/node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:18093)
+ at ProxyTracer.startActiveSpan (/home/alex/git/warme/node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:18854)
+ at /home/alex/git/warme/node_modules/next/dist/server/lib/trace/tracer.js:122:103
+ at NoopContextManager.with (/home/alex/git/warme/node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:7062)
+ at ContextAPI.with (/home/alex/git/warme/node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:518)
+2025-11-07T10:52:00: ⨯ The requested resource isn't a valid image for /img/christian-warme.jpg received null
+2025-11-07T10:52:06: ⨯ The requested resource isn't a valid image for /img/christian-warme.jpg received null
+2025-11-07T10:52:11: ⨯ The requested resource isn't a valid image for /img/christian-warme.jpg received null
+2025-11-07T10:54:38: ⨯ The requested resource isn't a valid image for /img/christian-warme.jpg received null
diff --git a/logs/out-0.log b/logs/out-0.log
index ade1d34..0dce4ad 100644
--- a/logs/out-0.log
+++ b/logs/out-0.log
@@ -46,3 +46,412 @@
2025-11-05T10:18:24: > Ready on http://localhost:3000
2025-11-05T10:25:45: > Ready on http://localhost:3000
2025-11-05T10:33:36: > Ready on http://localhost:3000
+2025-11-05T10:44:48: > Ready on http://localhost:3000
+2025-11-05T13:03:54: > Ready on http://localhost:3000
+2025-11-05T13:13:52: > Ready on http://localhost:3000
+2025-11-05T13:15:35: > Ready on http://localhost:3000
+2025-11-05T13:19:52: > Ready on http://localhost:3000
+2025-11-05T13:26:22: > Ready on http://localhost:3000
+2025-11-05T13:40:32: > Ready on http://localhost:3000
+2025-11-05T13:51:31: > Ready on http://localhost:3000
+2025-11-05T13:57:37: > Ready on http://localhost:3000
+2025-11-05T14:00:08: > Ready on http://localhost:3000
+2025-11-05T14:05:54: > Ready on http://localhost:3000
+2025-11-05T14:11:22: > Ready on http://localhost:3000
+2025-11-05T14:12:29: > Ready on http://localhost:3000
+2025-11-05T14:15:42: > Ready on http://localhost:3000
+2025-11-05T14:22:07: > Ready on http://localhost:3000
+2025-11-05T14:26:53: > Ready on http://localhost:3000
+2025-11-05T14:32:02: > Ready on http://localhost:3000
+2025-11-05T14:33:33: > Ready on http://localhost:3000
+2025-11-05T14:34:21: > Ready on http://localhost:3000
+2025-11-05T14:49:28: Middleware - Path: /admin
+2025-11-05T14:49:28: Middleware - Token present: true
+2025-11-05T14:49:28: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-05T14:49:28: Middleware - Invalid token: [Error: The edge runtime does not support Node.js 'crypto' module.
+Learn More: https://nextjs.org/docs/messages/node-module-in-edge-runtime]
+2025-11-05T14:49:29: Middleware - Path: /admin
+2025-11-05T14:49:29: Middleware - Token present: true
+2025-11-05T14:49:29: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-05T14:49:29: Middleware - Invalid token: [Error: The edge runtime does not support Node.js 'crypto' module.
+Learn More: https://nextjs.org/docs/messages/node-module-in-edge-runtime]
+2025-11-05T14:52:40: > Ready on http://localhost:3000
+2025-11-05T14:52:56: Middleware - Path: /admin
+2025-11-05T14:52:56: Middleware - Token present: true
+2025-11-05T14:52:56: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-05T14:52:56: Middleware - Token valid, user: brotha
+2025-11-05T14:52:56: Middleware - Path: /admin/availability
+2025-11-05T14:52:56: Middleware - Token present: true
+2025-11-05T14:52:56: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-05T14:52:56: Middleware - Path: /admin/bookings
+2025-11-05T14:52:56: Middleware - Token present: true
+2025-11-05T14:52:56: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-05T14:52:56: Middleware - Token valid, user: brotha
+2025-11-05T14:52:56: Middleware - Token valid, user: brotha
+2025-11-05T14:52:57: Middleware - Path: /admin
+2025-11-05T14:52:57: Middleware - Token present: true
+2025-11-05T14:52:57: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-05T14:52:57: Middleware - Token valid, user: brotha
+2025-11-05T14:53:56: Middleware - Path: /admin
+2025-11-05T14:53:56: Middleware - Token present: false
+2025-11-05T14:53:56: Middleware - Token value: undefined...
+2025-11-05T14:53:56: Middleware - No token, redirecting to login
+2025-11-05T14:54:33: Middleware - Path: /admin
+2025-11-05T14:54:33: Middleware - Token present: true
+2025-11-05T14:54:33: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-05T14:54:33: Middleware - Token valid, user: brotha
+2025-11-05T15:00:18: Middleware - Path: /admin
+2025-11-05T15:00:18: Middleware - Token present: true
+2025-11-05T15:00:18: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-05T15:00:18: Middleware - Token valid, user: brotha
+2025-11-05T15:00:18: Middleware - Path: /admin/availability
+2025-11-05T15:00:18: Middleware - Token present: true
+2025-11-05T15:00:18: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-05T15:00:18: Middleware - Path: /admin/bookings
+2025-11-05T15:00:18: Middleware - Token present: true
+2025-11-05T15:00:18: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-05T15:00:18: Middleware - Token valid, user: brotha
+2025-11-05T15:00:18: Middleware - Token valid, user: brotha
+2025-11-07T07:40:24: > Ready on http://localhost:3000
+2025-11-07T08:57:58: Middleware - Path: /admin
+2025-11-07T08:57:58: Middleware - Token present: true
+2025-11-07T08:57:58: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T08:57:58: Middleware - Token valid, user: brotha
+2025-11-07T08:57:58: Middleware - Path: /admin/availability
+2025-11-07T08:57:58: Middleware - Token present: true
+2025-11-07T08:57:58: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T08:57:58: Middleware - Path: /admin/bookings
+2025-11-07T08:57:58: Middleware - Token present: true
+2025-11-07T08:57:58: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T08:57:58: Middleware - Token valid, user: brotha
+2025-11-07T08:57:58: Middleware - Token valid, user: brotha
+2025-11-07T09:00:04: Middleware - Path: /admin
+2025-11-07T09:00:04: Middleware - Token present: true
+2025-11-07T09:00:04: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T09:00:04: Middleware - Token valid, user: brotha
+2025-11-07T09:01:41: > Ready on http://localhost:3000
+2025-11-07T09:02:27: === API LOGIN REQUEST START ===
+2025-11-07T09:02:27: Timestamp: 2025-11-07T08:02:27.169Z
+2025-11-07T09:02:27: Request body received: { username: 'brotha', passwordLength: 9 }
+2025-11-07T09:02:27: Connecting to database...
+2025-11-07T09:02:27: Database connected
+2025-11-07T09:02:27: Querying user from database: brotha
+2025-11-07T09:02:27: Query result: found 1 users
+2025-11-07T09:02:27: User found: {
+ id: 'd87cf9cf-ba4a-11f0-8c13-cffd87a9b9a6',
+ username: 'brotha',
+ role: 'admin'
+}
+2025-11-07T09:02:27: Verifying password with salt...
+2025-11-07T09:02:27: Password valid: true
+2025-11-07T09:02:27: Creating JWT token...
+2025-11-07T09:02:27: JWT token created, length: 239
+2025-11-07T09:02:27: Setting authToken cookie...
+2025-11-07T09:02:27: Cookie set successfully
+2025-11-07T09:02:27: === API LOGIN SUCCESS ===
+2025-11-07T09:02:27: Database connection closed
+2025-11-07T09:02:46: === API LOGIN REQUEST START ===
+2025-11-07T09:02:46: Timestamp: 2025-11-07T08:02:46.950Z
+2025-11-07T09:02:46: Request body received: { username: 'brotha', passwordLength: 9 }
+2025-11-07T09:02:46: Connecting to database...
+2025-11-07T09:02:46: Database connected
+2025-11-07T09:02:46: Querying user from database: brotha
+2025-11-07T09:02:46: Query result: found 1 users
+2025-11-07T09:02:46: User found: {
+ id: 'd87cf9cf-ba4a-11f0-8c13-cffd87a9b9a6',
+ username: 'brotha',
+ role: 'admin'
+}
+2025-11-07T09:02:46: Verifying password with salt...
+2025-11-07T09:02:47: Password valid: true
+2025-11-07T09:02:47: Creating JWT token...
+2025-11-07T09:02:47: JWT token created, length: 239
+2025-11-07T09:02:47: Setting authToken cookie...
+2025-11-07T09:02:47: Cookie set successfully
+2025-11-07T09:02:47: === API LOGIN SUCCESS ===
+2025-11-07T09:02:47: Database connection closed
+2025-11-07T09:02:47: Middleware - Path: /admin
+2025-11-07T09:02:47: Middleware - Token present: true
+2025-11-07T09:02:47: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T09:02:47: Middleware - Token valid, user: brotha
+2025-11-07T09:03:46: > Ready on http://localhost:3000
+2025-11-07T09:04:22: === API LOGIN REQUEST START ===
+2025-11-07T09:04:22: Timestamp: 2025-11-07T08:04:22.360Z
+2025-11-07T09:04:22: Request body received: { username: 'brotha', passwordLength: 9 }
+2025-11-07T09:04:22: Connecting to database...
+2025-11-07T09:04:22: Database connected
+2025-11-07T09:04:22: Querying user from database: brotha
+2025-11-07T09:04:22: Query result: found 1 users
+2025-11-07T09:04:22: User found: {
+ id: 'd87cf9cf-ba4a-11f0-8c13-cffd87a9b9a6',
+ username: 'brotha',
+ role: 'admin'
+}
+2025-11-07T09:04:22: Verifying password with salt...
+2025-11-07T09:04:22: Password valid: true
+2025-11-07T09:04:22: Creating JWT token...
+2025-11-07T09:04:22: JWT token created, length: 239
+2025-11-07T09:04:22: Setting authToken cookie...
+2025-11-07T09:04:22: Cookie set successfully
+2025-11-07T09:04:22: === API LOGIN SUCCESS ===
+2025-11-07T09:04:22: Database connection closed
+2025-11-07T09:04:22: Middleware - Path: /admin
+2025-11-07T09:04:22: Middleware - Token present: true
+2025-11-07T09:04:22: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T09:04:22: Middleware - Token valid, user: brotha
+2025-11-07T09:04:22: Middleware - Path: /admin/availability
+2025-11-07T09:04:22: Middleware - Token present: true
+2025-11-07T09:04:22: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T09:04:22: Middleware - Token valid, user: brotha
+2025-11-07T09:04:33: Middleware - Path: /admin/availability
+2025-11-07T09:04:33: Middleware - Token present: true
+2025-11-07T09:04:33: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T09:04:33: Middleware - Token valid, user: brotha
+2025-11-07T09:06:58: > Ready on http://localhost:3000
+2025-11-07T09:09:53: > Ready on http://localhost:3000
+2025-11-07T09:12:14: Middleware - Path: /admin
+2025-11-07T09:12:14: Middleware - Token present: true
+2025-11-07T09:12:14: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T09:12:14: Middleware - Token valid, user: brotha
+2025-11-07T09:14:03: > Ready on http://localhost:3000
+2025-11-07T09:19:57: > Ready on http://localhost:3000
+2025-11-07T09:39:02: > Ready on http://localhost:3000
+2025-11-07T09:44:40: > Ready on http://localhost:3000
+2025-11-07T09:44:53: > Ready on http://localhost:3000
+2025-11-07T09:45:19: === BOOKING REQUEST ===
+2025-11-07T09:45:19: Request body: {
+ "name": "Test Booking",
+ "email": "test@test.dk",
+ "phone": "12345678",
+ "address": "København",
+ "party_size": 20,
+ "date": "2025-11-15",
+ "start_time": "18:00",
+ "duration_hours": 3,
+ "menu_id": "klassisk-3-retter",
+ "include_service": true,
+ "include_cleanup": true,
+ "message": "Test booking"
+}
+2025-11-07T09:45:19: Booking time: { date: '2025-11-15', start: '18:00:00', end: '21:00:00', duration: 3 }
+2025-11-07T09:45:19: Blocked dates check: { cnt: 0 }
+2025-11-07T09:45:19: Checking availability for weekday: 6
+2025-11-07T09:45:19: Available slots for weekday: []
+2025-11-07T09:45:19: Time slot covered by availability: false
+2025-11-07T09:45:19: ERROR: Requested time is outside of availability
+2025-11-07T09:46:02: > Ready on http://localhost:3000
+2025-11-07T09:50:55: === BOOKING REQUEST ===
+2025-11-07T09:50:55: Request body: {
+ "name": "sfds",
+ "email": "dsffds",
+ "phone": "214214124",
+ "address": "2400",
+ "party_size": 15,
+ "date": "2025-11-04",
+ "start_time": "08:00",
+ "duration_hours": 3,
+ "menu_id": "italiensk-aften",
+ "package_id": null,
+ "include_service": true,
+ "include_cleanup": true,
+ "message": ""
+}
+2025-11-07T09:50:55: Booking time: { date: '2025-11-04', start: '08:00:00', end: '11:00:00', duration: 3 }
+2025-11-07T09:50:55: Blocked dates check: { cnt: 0 }
+2025-11-07T09:50:55: Checking availability for weekday: 2
+2025-11-07T09:50:55: Available slots for weekday: [
+ {
+ id: 'da219369-491d-4d34-a6ea-3f51808c0611',
+ weekday: 2,
+ start_time: '08:00:00',
+ end_time: '16:00:00',
+ note: 'arbejde',
+ created_at: 2025-10-29T10:29:01.000Z
+ }
+]
+2025-11-07T09:50:55: Time slot covered by availability: true
+2025-11-07T09:50:55: Overlapping bookings check: { cnt: 1 }
+2025-11-07T09:50:55: ERROR: Time slot already booked
+2025-11-07T09:58:35: > Ready on http://localhost:3000
+2025-11-07T09:59:54: === API: GET /api/availability/weekly ===
+2025-11-07T09:59:54: === API: GET /api/availability/blocked ===
+2025-11-07T09:59:54: ✓ Weekly availability fetched: 2 slots
+2025-11-07T09:59:54: ✓ Blocked dates fetched: 3 dates
+2025-11-07T10:00:14: === BOOKING REQUEST ===
+2025-11-07T10:00:14: Request body: {
+ "name": "bobbo",
+ "email": "bobo",
+ "phone": "1255151",
+ "address": "2400",
+ "party_size": 15,
+ "date": "2025-11-04",
+ "start_time": "10:00",
+ "duration_hours": 3,
+ "menu_id": "italiensk-aften",
+ "package_id": null,
+ "include_service": true,
+ "include_cleanup": false,
+ "message": ""
+}
+2025-11-07T10:00:14: Booking time: { date: '2025-11-04', start: '10:00:00', end: '13:00:00', duration: 3 }
+2025-11-07T10:00:14: Blocked dates check: { cnt: 0 }
+2025-11-07T10:00:14: Checking availability for weekday: 2
+2025-11-07T10:00:14: Available slots for weekday: [
+ {
+ id: 'da219369-491d-4d34-a6ea-3f51808c0611',
+ weekday: 2,
+ start_time: '08:00:00',
+ end_time: '16:00:00',
+ note: 'arbejde',
+ created_at: 2025-10-29T10:29:01.000Z
+ }
+]
+2025-11-07T10:00:14: Time slot covered by availability: true
+2025-11-07T10:00:14: Overlapping bookings check: { cnt: 1 }
+2025-11-07T10:00:14: ERROR: Time slot already booked
+2025-11-07T10:00:26: === BOOKING REQUEST ===
+2025-11-07T10:00:26: Request body: {
+ "name": "bobbo",
+ "email": "bobo",
+ "phone": "1255151",
+ "address": "2400",
+ "party_size": 15,
+ "date": "2025-11-04",
+ "start_time": "11:00",
+ "duration_hours": 3,
+ "menu_id": "italiensk-aften",
+ "package_id": null,
+ "include_service": true,
+ "include_cleanup": false,
+ "message": ""
+}
+2025-11-07T10:00:26: Booking time: { date: '2025-11-04', start: '11:00:00', end: '14:00:00', duration: 3 }
+2025-11-07T10:00:26: Blocked dates check: { cnt: 0 }
+2025-11-07T10:00:26: Checking availability for weekday: 2
+2025-11-07T10:00:26: Available slots for weekday: [
+ {
+ id: 'da219369-491d-4d34-a6ea-3f51808c0611',
+ weekday: 2,
+ start_time: '08:00:00',
+ end_time: '16:00:00',
+ note: 'arbejde',
+ created_at: 2025-10-29T10:29:01.000Z
+ }
+]
+2025-11-07T10:00:26: Time slot covered by availability: true
+2025-11-07T10:00:26: Overlapping bookings check: { cnt: 0 }
+2025-11-07T10:00:26: === BOOKING SUCCESS ===
+2025-11-07T10:00:26: Booking ID: 77b5b617-25d0-46c2-b0f4-42a1b92e0924
+2025-11-07T10:00:26: Total price: 5250
+2025-11-07T10:00:38: Middleware - Path: /admin/bookings
+2025-11-07T10:00:38: Middleware - Token present: true
+2025-11-07T10:00:38: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T10:00:38: Middleware - Token valid, user: brotha
+2025-11-07T10:00:38: === API: GET /api/bookings ===
+2025-11-07T10:00:38: Query params: { date: null }
+2025-11-07T10:00:38: ✓ All bookings fetched. Count: 2
+2025-11-07T10:04:54: > Ready on http://localhost:3000
+2025-11-07T10:27:43: === BOOKING REQUEST ===
+2025-11-07T10:27:43: Request body: {
+ "name": "gffdgdgf",
+ "email": "dfgdfgdgf",
+ "phone": "24124124",
+ "address": "2400",
+ "party_size": 15,
+ "date": "2025-11-04",
+ "start_time": "10:30",
+ "duration_hours": 3,
+ "menu_id": "italiensk-aften",
+ "package_id": null,
+ "include_service": true,
+ "include_cleanup": false,
+ "message": "fafssfsf"
+}
+2025-11-07T10:27:43: Booking time: { date: '2025-11-04', start: '10:30:00', end: '13:30:00', duration: 3 }
+2025-11-07T10:27:43: Blocked dates check: { cnt: 0 }
+2025-11-07T10:27:43: Checking availability for weekday: 2
+2025-11-07T10:27:43: Available slots for weekday: [
+ {
+ id: 'da219369-491d-4d34-a6ea-3f51808c0611',
+ weekday: 2,
+ start_time: '08:00:00',
+ end_time: '16:00:00',
+ note: 'arbejde',
+ created_at: 2025-10-29T10:29:01.000Z
+ }
+]
+2025-11-07T10:27:43: Time slot covered by availability: true
+2025-11-07T10:27:43: Overlapping bookings check: { cnt: 2 }
+2025-11-07T10:27:43: ERROR: Time slot already booked
+2025-11-07T10:28:06: === BOOKING REQUEST ===
+2025-11-07T10:28:06: Request body: {
+ "name": "gffdgdgf",
+ "email": "dfgdfgdgf",
+ "phone": "24124124",
+ "address": "2400",
+ "party_size": 15,
+ "date": "2025-11-18",
+ "start_time": "10:00",
+ "duration_hours": 3,
+ "menu_id": "brunch-luksus",
+ "package_id": null,
+ "include_service": true,
+ "include_cleanup": false,
+ "message": "fafssfsf"
+}
+2025-11-07T10:28:06: Booking time: { date: '2025-11-18', start: '10:00:00', end: '13:00:00', duration: 3 }
+2025-11-07T10:28:06: Blocked dates check: { cnt: 0 }
+2025-11-07T10:28:06: Checking availability for weekday: 2
+2025-11-07T10:28:06: Available slots for weekday: [
+ {
+ id: 'da219369-491d-4d34-a6ea-3f51808c0611',
+ weekday: 2,
+ start_time: '08:00:00',
+ end_time: '16:00:00',
+ note: 'arbejde',
+ created_at: 2025-10-29T10:29:01.000Z
+ }
+]
+2025-11-07T10:28:06: Time slot covered by availability: true
+2025-11-07T10:28:06: Overlapping bookings check: { cnt: 0 }
+2025-11-07T10:28:06: === BOOKING SUCCESS ===
+2025-11-07T10:28:06: Booking ID: f09136a1-7766-42a7-8f67-f02200137c98
+2025-11-07T10:28:06: Total price: 10175
+2025-11-07T10:31:38: > Ready on http://localhost:3000
+2025-11-07T10:44:13: > Ready on http://localhost:3000
+2025-11-07T10:45:42: === API: GET /api/availability/weekly ===
+2025-11-07T10:45:42: ✓ Weekly availability fetched: 2 slots
+2025-11-07T10:48:04: Middleware - Path: /admin/bookings
+2025-11-07T10:48:04: Middleware - Token present: true
+2025-11-07T10:48:04: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T10:48:04: Middleware - Token valid, user: brotha
+2025-11-07T10:48:08: === API: GET /api/availability/weekly ===
+2025-11-07T10:48:08: === API: GET /api/availability/blocked ===
+2025-11-07T10:48:08: ✓ Weekly availability fetched: 2 slots
+2025-11-07T10:48:08: ✓ Blocked dates fetched: 3 dates
+2025-11-07T10:48:18: > Ready on http://localhost:3000
+2025-11-07T10:48:24: === API: GET /api/availability/weekly ===
+2025-11-07T10:48:24: === API: GET /api/availability/blocked ===
+2025-11-07T10:48:24: ✓ Weekly availability fetched: 2 slots
+2025-11-07T10:48:24: ✓ Blocked dates fetched: 3 dates
+2025-11-07T10:49:46: > Ready on http://localhost:3000
+2025-11-07T10:52:03: Middleware - Path: /admin/bookings
+2025-11-07T10:52:03: Middleware - Token present: true
+2025-11-07T10:52:03: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T10:52:03: Middleware - Token valid, user: brotha
+2025-11-07T10:52:03: === API: GET /api/bookings ===
+2025-11-07T10:52:03: Query params: { date: null }
+2025-11-07T10:52:03: Middleware - Path: /admin
+2025-11-07T10:52:03: Middleware - Token present: true
+2025-11-07T10:52:03: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T10:52:03: Middleware - Path: /admin/availability
+2025-11-07T10:52:03: Middleware - Token present: true
+2025-11-07T10:52:03: Middleware - Token value: eyJhbGciOiJIUzI1NiIs...
+2025-11-07T10:52:03: Middleware - Token valid, user: brotha
+2025-11-07T10:52:03: Middleware - Token valid, user: brotha
+2025-11-07T10:52:03: ✓ All bookings fetched. Count: 3
+2025-11-07T10:52:58: > Ready on http://localhost:3000
+2025-11-07T10:54:04: === API: GET /api/availability/weekly ===
+2025-11-07T10:54:04: === API: GET /api/availability/blocked ===
+2025-11-07T10:54:04: ✓ Blocked dates fetched: 3 dates
+2025-11-07T10:54:04: ✓ Weekly availability fetched: 2 slots
diff --git a/package-lock.json b/package-lock.json
index 4b46e4d..18c3431 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,10 +8,14 @@
"name": "warme",
"version": "0.1.0",
"dependencies": {
+ "@types/bcrypt": "^6.0.0",
+ "@types/jsonwebtoken": "^9.0.10",
+ "bcrypt": "^6.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"dotenv": "^17.2.3",
+ "jsonwebtoken": "^9.0.2",
"lucide-react": "^0.292.0",
"mysql2": "^3.15.3",
"next": "^14.2.33",
@@ -26,6 +30,7 @@
"devDependencies": {
"@eslint/config-array": "^0.21.1",
"@eslint/object-schema": "^2.1.7",
+ "@playwright/test": "^1.56.1",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
@@ -1011,6 +1016,22 @@
"node": ">=14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
+ "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.56.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1052,6 +1073,15 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/bcrypt": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
+ "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -1059,11 +1089,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/jsonwebtoken": {
+ "version": "9.0.10",
+ "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
+ "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "20.19.24",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
"integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -1876,6 +1921,20 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
+ "node_modules/bcrypt": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
+ "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-addon-api": "^8.3.0",
+ "node-gyp-build": "^4.8.4"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1947,6 +2006,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -2432,6 +2497,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.241",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.241.tgz",
@@ -4352,6 +4426,28 @@
"json5": "lib/cli.js"
}
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
+ "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^3.2.2",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -4368,6 +4464,27 @@
"node": ">=4.0"
}
},
+ "node_modules/jwa": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
+ "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
+ "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^1.4.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4448,6 +4565,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4455,6 +4608,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -4578,7 +4737,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/mysql2": {
@@ -4753,6 +4911,26 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/node-addon-api": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
+ "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
@@ -5144,6 +5322,53 @@
"node": ">= 6"
}
},
+ "node_modules/playwright": {
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
+ "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.56.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
+ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5599,6 +5824,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -6637,7 +6882,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
diff --git a/package.json b/package.json
index 56d05db..960a30a 100644
--- a/package.json
+++ b/package.json
@@ -22,10 +22,14 @@
"addFeatured": "node scripts/add-featured-column.mjs"
},
"dependencies": {
+ "@types/bcrypt": "^6.0.0",
+ "@types/jsonwebtoken": "^9.0.10",
+ "bcrypt": "^6.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"dotenv": "^17.2.3",
+ "jsonwebtoken": "^9.0.2",
"lucide-react": "^0.292.0",
"mysql2": "^3.15.3",
"next": "^14.2.33",
@@ -40,6 +44,7 @@
"devDependencies": {
"@eslint/config-array": "^0.21.1",
"@eslint/object-schema": "^2.1.7",
+ "@playwright/test": "^1.56.1",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
diff --git a/playwright-report/data/fd20cb4fab8ff344c3ca7f7ca29dd378d3dce484.md b/playwright-report/data/fd20cb4fab8ff344c3ca7f7ca29dd378d3dce484.md
new file mode 100644
index 0000000..b53929f
--- /dev/null
+++ b/playwright-report/data/fd20cb4fab8ff344c3ca7f7ca29dd378d3dce484.md
@@ -0,0 +1,114 @@
+# Page snapshot
+
+```yaml
+- generic [active] [ref=e1]:
+ - generic [ref=e4]:
+ - complementary [ref=e5]:
+ - heading "Admin" [level=2] [ref=e6]
+ - navigation [ref=e7]:
+ - link "Menuer" [ref=e8] [cursor=pointer]:
+ - /url: /admin
+ - link "Tilgængelighed" [ref=e9] [cursor=pointer]:
+ - /url: /admin/availability
+ - link "Bookinger" [ref=e10] [cursor=pointer]:
+ - /url: /admin/bookings
+ - main [ref=e11]:
+ - generic [ref=e13]:
+ - generic [ref=e14]:
+ - heading "Din Arbejdskalender" [level=1] [ref=e15]
+ - paragraph [ref=e16]: Klik på en dag for at åbne eller lukke for bookinger
+ - generic [ref=e17]:
+ - heading "Hvad betyder farverne?" [level=2] [ref=e18]
+ - generic [ref=e19]:
+ - generic [ref=e22]:
+ - generic [ref=e23]: ÅBEN DAG
+ - generic [ref=e24]: Kunder kan booke dig
+ - generic [ref=e27]:
+ - generic [ref=e28]: LUKKET DAG
+ - generic [ref=e29]: Ingen bookinger mulige
+ - generic [ref=e30]:
+ - generic [ref=e31]:
+ - heading "Klik på en dag for at ændre status" [level=2] [ref=e32]
+ - paragraph [ref=e33]: Grønne dage = åbne for booking | Røde dage = lukkede
+ - generic [ref=e39]:
+ - generic [ref=e40]:
+ - generic [ref=e41]: november 2025
+ - generic:
+ - button "Go to previous month" [ref=e42] [cursor=pointer]:
+ - img [ref=e43]
+ - button "Go to next month" [ref=e45] [cursor=pointer]:
+ - img [ref=e46]
+ - grid "november 2025" [ref=e48]:
+ - rowgroup [ref=e49]:
+ - row "mandag tirsdag onsdag torsdag fredag lørdag søndag" [ref=e50]:
+ - columnheader "mandag" [ref=e51]: ma
+ - columnheader "tirsdag" [ref=e52]: ti
+ - columnheader "onsdag" [ref=e53]: "on"
+ - columnheader "torsdag" [ref=e54]: to
+ - columnheader "fredag" [ref=e55]: fr
+ - columnheader "lørdag" [ref=e56]: lø
+ - columnheader "søndag" [ref=e57]: sø
+ - rowgroup [ref=e58]:
+ - row "1 2" [ref=e59]:
+ - gridcell
+ - gridcell
+ - gridcell
+ - gridcell
+ - gridcell
+ - gridcell "1" [ref=e60] [cursor=pointer]
+ - gridcell "2" [ref=e61] [cursor=pointer]
+ - row "3 4 5 6 7 8 9" [ref=e62]:
+ - gridcell "3" [ref=e63] [cursor=pointer]
+ - gridcell "4" [ref=e64] [cursor=pointer]
+ - gridcell "5" [ref=e65] [cursor=pointer]
+ - gridcell "6" [ref=e66] [cursor=pointer]
+ - gridcell "7" [ref=e67] [cursor=pointer]
+ - gridcell "8" [ref=e68] [cursor=pointer]
+ - gridcell "9" [ref=e69] [cursor=pointer]
+ - row "10 11 12 13 14 15 16" [ref=e70]:
+ - gridcell "10" [ref=e71] [cursor=pointer]
+ - gridcell "11" [ref=e72] [cursor=pointer]
+ - gridcell "12" [ref=e73] [cursor=pointer]
+ - gridcell "13" [ref=e74] [cursor=pointer]
+ - gridcell "14" [ref=e75] [cursor=pointer]
+ - gridcell "15" [ref=e76] [cursor=pointer]
+ - gridcell "16" [ref=e77] [cursor=pointer]
+ - row "17 18 19 20 21 22 23" [ref=e78]:
+ - gridcell "17" [ref=e79] [cursor=pointer]
+ - gridcell "18" [ref=e80] [cursor=pointer]
+ - gridcell "19" [ref=e81] [cursor=pointer]
+ - gridcell "20" [ref=e82] [cursor=pointer]
+ - gridcell "21" [ref=e83] [cursor=pointer]
+ - gridcell "22" [ref=e84] [cursor=pointer]
+ - gridcell "23" [ref=e85] [cursor=pointer]
+ - row "24 25 26 27 28 29 30" [ref=e86]:
+ - gridcell "24" [ref=e87] [cursor=pointer]
+ - gridcell "25" [ref=e88] [cursor=pointer]
+ - gridcell "26" [ref=e89] [cursor=pointer]
+ - gridcell "27" [ref=e90] [cursor=pointer]
+ - gridcell "28" [ref=e91] [cursor=pointer]
+ - gridcell "29" [ref=e92] [cursor=pointer]
+ - gridcell "30" [ref=e93] [cursor=pointer]
+ - generic [ref=e94]:
+ - heading "Dine lukkede dage" [level=2] [ref=e95]
+ - generic [ref=e96]:
+ - generic [ref=e97]:
+ - generic [ref=e100]: onsdag 5. november
+ - button "Åbn dag" [ref=e101] [cursor=pointer]
+ - generic [ref=e102]:
+ - generic [ref=e105]: torsdag 6. november
+ - button "Åbn dag" [ref=e106] [cursor=pointer]
+ - generic [ref=e107]:
+ - heading "Sådan fungerer det" [level=3] [ref=e108]
+ - generic [ref=e109]:
+ - paragraph [ref=e110]:
+ - text: 🟢
+ - strong [ref=e111]: Grøn dag
+ - text: = Kunder kan booke dig (klik for at lukke)
+ - paragraph [ref=e112]:
+ - text: 🔴
+ - strong [ref=e113]: Rød dag
+ - text: = Lukket for bookinger (klik for at åbne)
+ - paragraph [ref=e114]: 📱 Alle ændringer gemmes automatisk
+ - alert [ref=e115]
+```
\ No newline at end of file
diff --git a/playwright-report/index.html b/playwright-report/index.html
new file mode 100644
index 0000000..14caf80
--- /dev/null
+++ b/playwright-report/index.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..654139d
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './',
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: 1,
+ reporter: 'html',
+ use: {
+ baseURL: 'http://localhost:3000',
+ trace: 'on-first-retry',
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+
+ webServer: {
+ command: 'echo "Server already running"',
+ port: 3000,
+ reuseExistingServer: true,
+ },
+});
\ No newline at end of file
diff --git a/public/img/christianprofile.png b/public/img/christianprofile.png
new file mode 100644
index 0000000..5d68a21
Binary files /dev/null and b/public/img/christianprofile.png differ
diff --git a/scripts/add-address-to-bookings.mjs b/scripts/add-address-to-bookings.mjs
new file mode 100644
index 0000000..3c0b6a3
--- /dev/null
+++ b/scripts/add-address-to-bookings.mjs
@@ -0,0 +1,51 @@
+import mysql from 'mysql2/promise'
+import dotenv from 'dotenv'
+import { fileURLToPath } from 'url'
+import { dirname, join } from 'path'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = dirname(__filename)
+
+// Load environment variables from .env file in parent directory
+dotenv.config({ path: join(__dirname, '..', '.env') })
+
+async function addAddressColumn() {
+ const connection = await mysql.createConnection({
+ host: process.env.DB_HOST,
+ user: process.env.DB_USER,
+ password: process.env.DB_PASSWORD,
+ database: process.env.DB_NAME,
+ })
+
+ try {
+ console.log('Checking if address column exists in bookings table...')
+
+ // Check if column exists
+ const [columns] = await connection.query(
+ `SELECT COLUMN_NAME
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = ?
+ AND TABLE_NAME = 'bookings'
+ AND COLUMN_NAME = 'address'`,
+ [process.env.DB_NAME]
+ )
+
+ if (columns.length > 0) {
+ console.log('✓ Address column already exists')
+ } else {
+ console.log('Adding address column to bookings table...')
+ await connection.query(
+ `ALTER TABLE bookings ADD COLUMN address VARCHAR(255) DEFAULT NULL AFTER phone`
+ )
+ console.log('✓ Address column added successfully')
+ }
+
+ } catch (error) {
+ console.error('Error:', error)
+ process.exit(1)
+ } finally {
+ await connection.end()
+ }
+}
+
+addAddressColumn()
diff --git a/scripts/add-booking-extras.mjs b/scripts/add-booking-extras.mjs
new file mode 100644
index 0000000..ef426e5
--- /dev/null
+++ b/scripts/add-booking-extras.mjs
@@ -0,0 +1,72 @@
+import mysql from 'mysql2/promise'
+import dotenv from 'dotenv'
+import { fileURLToPath } from 'url'
+import { dirname, join } from 'path'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = dirname(__filename)
+
+dotenv.config({ path: join(__dirname, '..', '.env') })
+
+async function addBookingExtras() {
+ const connection = await mysql.createConnection({
+ host: process.env.DB_HOST,
+ user: process.env.DB_USER,
+ password: process.env.DB_PASSWORD,
+ database: process.env.DB_NAME,
+ })
+
+ try {
+ console.log('Adding booking extras columns...')
+
+ // Check and add include_service
+ const [serviceCol] = await connection.query(
+ `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'bookings' AND COLUMN_NAME = 'include_service'`,
+ [process.env.DB_NAME]
+ )
+
+ if (serviceCol.length === 0) {
+ await connection.query(`ALTER TABLE bookings ADD COLUMN include_service BOOLEAN DEFAULT FALSE`)
+ console.log('✓ Added include_service column')
+ } else {
+ console.log('✓ include_service column already exists')
+ }
+
+ // Check and add include_cleanup
+ const [cleanupCol] = await connection.query(
+ `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'bookings' AND COLUMN_NAME = 'include_cleanup'`,
+ [process.env.DB_NAME]
+ )
+
+ if (cleanupCol.length === 0) {
+ await connection.query(`ALTER TABLE bookings ADD COLUMN include_cleanup BOOLEAN DEFAULT FALSE`)
+ console.log('✓ Added include_cleanup column')
+ } else {
+ console.log('✓ include_cleanup column already exists')
+ }
+
+ // Check and add total_price
+ const [priceCol] = await connection.query(
+ `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'bookings' AND COLUMN_NAME = 'total_price'`,
+ [process.env.DB_NAME]
+ )
+
+ if (priceCol.length === 0) {
+ await connection.query(`ALTER TABLE bookings ADD COLUMN total_price DECIMAL(10,2) DEFAULT NULL`)
+ console.log('✓ Added total_price column')
+ } else {
+ console.log('✓ total_price column already exists')
+ }
+
+ } catch (error) {
+ console.error('Error:', error)
+ process.exit(1)
+ } finally {
+ await connection.end()
+ }
+}
+
+addBookingExtras()
diff --git a/scripts/setup-users.mjs b/scripts/setup-users.mjs
new file mode 100644
index 0000000..cb6834d
--- /dev/null
+++ b/scripts/setup-users.mjs
@@ -0,0 +1,59 @@
+import { createConnection } from 'mysql2/promise'
+
+async function createUsersTable() {
+ const connection = await createConnection({
+ host: process.env.DB_HOST || 'localhost',
+ user: process.env.DB_USER || 'root',
+ password: process.env.DB_PASSWORD || '',
+ database: process.env.DB_NAME || 'warme'
+ })
+
+ try {
+ // Create users table
+ await connection.execute(`
+ CREATE TABLE IF NOT EXISTS users (
+ id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
+ username VARCHAR(50) UNIQUE NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ salt VARCHAR(32) NOT NULL,
+ role ENUM('admin') DEFAULT 'admin',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ )
+ `)
+
+ console.log('✅ Users table created successfully')
+
+ // Check if admin user exists
+ const [rows] = await connection.execute(
+ 'SELECT * FROM users WHERE username = ?',
+ ['brotha']
+ )
+
+ if (rows.length === 0) {
+ // Create admin user with salted password
+ const bcrypt = await import('bcrypt')
+ const crypto = await import('crypto')
+
+ const salt = crypto.default.randomBytes(16).toString('hex')
+ const saltedPassword = 'makefood1' + salt
+ const passwordHash = await bcrypt.default.hash(saltedPassword, 12)
+
+ await connection.execute(
+ 'INSERT INTO users (username, password_hash, salt, role) VALUES (?, ?, ?, ?)',
+ ['brotha', passwordHash, salt, 'admin']
+ )
+
+ console.log('✅ Admin user created with salted password')
+ } else {
+ console.log('ℹ️ Admin user already exists')
+ }
+
+ } catch (error) {
+ console.error('❌ Error setting up users table:', error)
+ } finally {
+ await connection.end()
+ }
+}
+
+createUsersTable().catch(console.error)
\ No newline at end of file
diff --git a/src/app/(marketing)/layout.tsx b/src/app/(marketing)/layout.tsx
index c5ce362..4d2bf59 100644
--- a/src/app/(marketing)/layout.tsx
+++ b/src/app/(marketing)/layout.tsx
@@ -5,6 +5,7 @@ import { sampleData } from '@/config/sample-data'
const navigation = [
{ name: 'Menuer', href: '/menuer' },
{ name: 'Pakker', href: '/pakker' },
+ { name: 'Om Christian', href: '/om-christian' },
{ name: 'Book', href: '/book' },
{ name: 'Kontakt', href: '/kontakt' },
]
diff --git a/src/app/admin/availability/page.tsx b/src/app/admin/availability/page.tsx
index bdc4a71..316cfcb 100644
--- a/src/app/admin/availability/page.tsx
+++ b/src/app/admin/availability/page.tsx
@@ -3,6 +3,15 @@
import { useEffect, useState } from 'react'
import { DayPicker } from 'react-day-picker'
import { da } from 'date-fns/locale'
+
+// Custom locale with correct week start
+const dkLocale = {
+ ...da,
+ options: {
+ ...da.options,
+ weekStartsOn: 1 as const // Monday
+ }
+}
import { ToastProvider, useToast } from '@/components/ui/toast'
type Blocked = { id: string; block_date: string; note?: string }
@@ -160,12 +169,16 @@ function AvailabilityAdminContent() {
-
+ {
if (date && !saving) {
console.log('Clicking on date:', date)
@@ -180,6 +193,7 @@ function AvailabilityAdminContent() {
}}
disabled={saving}
/>
+
diff --git a/src/app/admin/bookings/bookings-client.tsx b/src/app/admin/bookings/bookings-client.tsx
index 5a218b6..75b5671 100644
--- a/src/app/admin/bookings/bookings-client.tsx
+++ b/src/app/admin/bookings/bookings-client.tsx
@@ -7,12 +7,16 @@ type Booking = {
name: string
email: string
phone?: string
+ address?: string
party_size?: number
booking_date: string
start_time: string
end_time: string
menu_id?: string
package_id?: string
+ include_service?: boolean
+ include_cleanup?: boolean
+ total_price?: number
message?: string
status?: string
}
@@ -23,12 +27,15 @@ export default function BookingsClient() {
const [cancelling, setCancelling] = useState(null)
async function fetchBookings() {
+ console.log('=== Fetching bookings ===')
try {
const r = await fetch('/api/bookings')
+ console.log('Response status:', r.status)
const data = await r.ok ? await r.json() : []
+ console.log('✓ Bookings loaded:', Array.isArray(data) ? data.length : 0)
setBookings(Array.isArray(data) ? data : [])
} catch (err) {
- console.error(err)
+ console.error('❌ Error fetching bookings:', err)
} finally {
setLoading(false)
}
@@ -39,23 +46,32 @@ export default function BookingsClient() {
}, [])
async function cancelBooking(id: string, name: string) {
- if (!confirm(`Annuller booking for ${name}?\n\nDette kan ikke fortrydes.`)) return
+ console.log('=== Cancel booking ===', { id, name })
+ if (!confirm(`Annuller booking for ${name}?\n\nDette kan ikke fortrydes.`)) {
+ console.log('Cancelled by user')
+ return
+ }
setCancelling(id)
try {
+ console.log('Sending DELETE request for booking:', id)
const res = await fetch('/api/bookings', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
})
+ console.log('Response status:', res.status)
if (res.ok) {
+ console.log('✓ Booking cancelled successfully')
setBookings(prev => prev.filter(b => b.id !== id))
alert(`Booking for ${name} er annulleret`)
} else {
+ console.log('❌ Failed to cancel booking')
alert('Kunne ikke annullere booking — prøv igen')
}
} catch (err) {
+ console.error('❌ Exception cancelling booking:', err)
alert('Fejl ved annullering — prøv igen')
} finally {
setCancelling(null)
@@ -65,33 +81,98 @@ export default function BookingsClient() {
if (loading) return Indlæser bookinger...
if (bookings.length === 0) return Ingen bookinger fundet.
+ // Calculate total revenue and profit
+ const totalRevenue = bookings.reduce((sum, b) => sum + (b.total_price || 0), 0)
+ // Rough estimate: 40% profit margin
+ const estimatedProfit = totalRevenue * 0.4
+
return (
-
+
+ {/* Financial summary */}
+
+
+
Total bookinger
+
{bookings.length}
+
+
+
Total omsætning
+
{totalRevenue.toLocaleString('da-DK')} kr
+
+
+
Estimeret fortjeneste (40%)
+
{estimatedProfit.toLocaleString('da-DK')} kr
+
+
+
+ {/* Bookings table */}
+
-
- Dato
- Tid
- Navn
- Kuverter
- Menu
- Note
- Handling
+
+ Ordre nr.
+ Dato
+ Tid
+ Navn
+ Kuverter
+ Menu
+ Tilvalg
+ Total pris
+ Note
+ Handling
{bookings.map(b => (
- {b.booking_date}
- {b.start_time.slice(0,5)} - {b.end_time.slice(0,5)}
-
- {b.name}
- {b.email}{b.phone ? ` · ${b.phone}` : ''}
+
+
+ {b.id.substring(0, 8).toUpperCase()}
+
- {b.party_size || '-'}
- {b.menu_id || b.package_id || '-'}
- {b.message || '-'}
-
+ {b.booking_date}
+ {b.start_time.slice(0,5)} - {b.end_time.slice(0,5)}
+
+ {b.name}
+ {b.email}
+ {b.phone && {b.phone}
}
+ {b.address && {b.address}
}
+
+ {b.party_size || '-'}
+
+ {b.menu_id || '-'}
+ {b.package_id && {b.package_id}
}
+
+
+
+ {b.include_service && (
+
+ ✓
+ Servering
+
+ )}
+ {b.include_cleanup && (
+
+ ✓
+ Oprydning
+
+ )}
+ {!b.include_service && !b.include_cleanup &&
- }
+
+
+
+ {b.total_price ? (
+
+
{b.total_price.toLocaleString('da-DK')} kr
+
+ Fortjeneste: ~{(b.total_price * 0.4).toLocaleString('da-DK')} kr
+
+
+ ) : '-'}
+
+
+ {b.message || '-'}
+
+
cancelBooking(b.id, b.name)}
disabled={cancelling === b.id}
@@ -104,6 +185,7 @@ export default function BookingsClient() {
))}
+
)
}
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
index bf17019..9995d98 100644
--- a/src/app/admin/layout.tsx
+++ b/src/app/admin/layout.tsx
@@ -1,4 +1,5 @@
import Link from 'next/link'
+import { LogoutButton } from '@/components/admin/logout-button'
export const metadata = {
title: 'Admin - varme.dk'
@@ -15,6 +16,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
Menuer
Tilgængelighed
Bookinger
+
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
new file mode 100644
index 0000000..260213d
--- /dev/null
+++ b/src/app/api/auth/login/route.ts
@@ -0,0 +1,123 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { createConnection } from 'mysql2/promise'
+import bcrypt from 'bcrypt'
+import jwt from 'jsonwebtoken'
+
+const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this'
+
+export async function POST(request: NextRequest) {
+ console.log('=== API LOGIN REQUEST START ===')
+ console.log('Timestamp:', new Date().toISOString())
+
+ try {
+ const body = await request.json()
+ console.log('Request body received:', { username: body.username, passwordLength: body.password?.length })
+
+ const { username, password } = body
+
+ if (!username || !password) {
+ console.log('ERROR: Missing username or password')
+ return NextResponse.json(
+ { error: 'Brugernavn og adgangskode er påkrævet' },
+ { status: 400 }
+ )
+ }
+
+ console.log('Connecting to database...')
+ // Connect to database
+ const connection = await createConnection({
+ host: process.env.DB_HOST,
+ user: process.env.DB_USER,
+ password: process.env.DB_PASSWORD,
+ database: process.env.DB_NAME,
+ })
+ console.log('Database connected')
+
+ try {
+ // Get user from database
+ console.log('Querying user from database:', username)
+ const [rows] = await connection.execute(
+ 'SELECT * FROM users WHERE username = ?',
+ [username]
+ )
+
+ const users = rows as any[]
+ console.log('Query result: found', users.length, 'users')
+
+ if (users.length === 0) {
+ console.log('ERROR: User not found:', username)
+ return NextResponse.json(
+ { error: 'Ugyldig brugernavn eller adgangskode' },
+ { status: 401 }
+ )
+ }
+
+ const user = users[0]
+ console.log('User found:', { id: user.id, username: user.username, role: user.role })
+
+ // Verify password with salt
+ console.log('Verifying password with salt...')
+ const saltedPassword = password + user.salt
+ const isValid = await bcrypt.compare(saltedPassword, user.password_hash)
+ console.log('Password valid:', isValid)
+
+ if (!isValid) {
+ console.log('ERROR: Invalid password for user:', username)
+ return NextResponse.json(
+ { error: 'Ugyldig brugernavn eller adgangskode' },
+ { status: 401 }
+ )
+ }
+
+ // Create JWT token
+ console.log('Creating JWT token...')
+ const token = jwt.sign(
+ {
+ userId: user.id,
+ username: user.username,
+ role: user.role
+ },
+ JWT_SECRET,
+ { expiresIn: '24h' }
+ )
+ console.log('JWT token created, length:', token.length)
+
+ // Set HTTP-only cookie
+ const response = NextResponse.json({
+ success: true,
+ token,
+ user: {
+ id: user.id,
+ username: user.username,
+ role: user.role
+ }
+ })
+
+ console.log('Setting authToken cookie...')
+ response.cookies.set('authToken', token, {
+ httpOnly: true,
+ secure: false, // Allow on HTTP for localhost
+ sameSite: 'lax', // Less strict for development
+ maxAge: 24 * 60 * 60 // 24 hours
+ })
+ console.log('Cookie set successfully')
+
+ console.log('=== API LOGIN SUCCESS ===')
+ return response
+
+ } finally {
+ await connection.end()
+ console.log('Database connection closed')
+ }
+
+ } catch (error) {
+ console.error('=== API LOGIN EXCEPTION ===')
+ console.error('Error:', error)
+ console.error('Error type:', typeof error)
+ console.error('Error stack:', error instanceof Error ? error.stack : 'N/A')
+ return NextResponse.json(
+ { error: 'Der skete en server fejl' },
+ { status: 500 }
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..c49e392
--- /dev/null
+++ b/src/app/api/auth/logout/route.ts
@@ -0,0 +1,15 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+export async function POST(request: NextRequest) {
+ // Clear auth cookie
+ const response = NextResponse.json({ success: true })
+
+ response.cookies.set('authToken', '', {
+ httpOnly: true,
+ secure: false, // Allow on HTTP for localhost
+ sameSite: 'lax',
+ maxAge: 0 // Expire immediately
+ })
+
+ return response
+}
\ No newline at end of file
diff --git a/src/app/api/availability/blocked/route.ts b/src/app/api/availability/blocked/route.ts
index 290b158..d66afa5 100644
--- a/src/app/api/availability/blocked/route.ts
+++ b/src/app/api/availability/blocked/route.ts
@@ -3,44 +3,53 @@ import { pool } from '@/lib/db'
import crypto from 'crypto'
export async function GET() {
+ console.log('=== API: GET /api/availability/blocked ===')
try {
const [rows] = await pool.query('SELECT * FROM blocked_dates ORDER BY block_date')
+ console.log('✓ Blocked dates fetched:', Array.isArray(rows) ? rows.length : 0, 'dates')
return NextResponse.json(rows)
} catch (error) {
- console.error('Error fetching blocked dates', error)
+ console.error('❌ Error fetching blocked dates:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
export async function POST(req: Request) {
+ console.log('=== API: POST /api/availability/blocked ===')
try {
- const body = await req.json()
- const { block_date, note } = body
- if (!block_date) return NextResponse.json({ error: 'Missing block_date' }, { status: 400 })
- // Prevent duplicate blocked dates
- const [existing] = await pool.query('SELECT COUNT(*) AS cnt FROM blocked_dates WHERE block_date = ?', [block_date])
- // @ts-ignore
- if (existing[0].cnt > 0) {
- return NextResponse.json({ error: 'Date already blocked' }, { status: 409 })
+ const { date } = await req.json()
+ console.log('Request body:', { date })
+
+ if (!date) {
+ console.log('❌ Missing date in request')
+ return NextResponse.json({ error: 'Missing date' }, { status: 400 })
}
- const id = crypto.randomUUID()
- await pool.query('INSERT INTO blocked_dates SET ?', [{ id, block_date, note }])
- return NextResponse.json({ id, block_date, note })
+ await pool.query('INSERT INTO blocked_dates (block_date) VALUES (?) ON DUPLICATE KEY UPDATE block_date=block_date', [date])
+ console.log('✓ Date blocked successfully:', date)
+ return NextResponse.json({ ok: true })
} catch (error) {
- console.error('Error creating blocked date', error)
+ console.error('❌ Error blocking date:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
export async function DELETE(req: Request) {
+ console.log('=== API: DELETE /api/availability/blocked ===')
try {
- const { id } = await req.json()
- if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
- await pool.query('DELETE FROM blocked_dates WHERE id = ?', [id])
+ const { date } = await req.json()
+ console.log('Request body:', { date })
+
+ if (!date) {
+ console.log('❌ Missing date in request')
+ return NextResponse.json({ error: 'Missing date' }, { status: 400 })
+ }
+
+ const [result] = await pool.query('DELETE FROM blocked_dates WHERE block_date = ?', [date])
+ console.log('✓ Date unblocked successfully:', date, 'Affected rows:', (result as any).affectedRows)
return NextResponse.json({ ok: true })
} catch (error) {
- console.error('Error deleting blocked date', error)
+ console.error('❌ Error unblocking date:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
diff --git a/src/app/api/availability/weekly/route.ts b/src/app/api/availability/weekly/route.ts
index 768aa68..6e2c8a3 100644
--- a/src/app/api/availability/weekly/route.ts
+++ b/src/app/api/availability/weekly/route.ts
@@ -3,70 +3,98 @@ import { pool } from '@/lib/db'
import crypto from 'crypto'
export async function GET() {
+ console.log('=== API: GET /api/availability/weekly ===')
try {
const [rows] = await pool.query('SELECT * FROM weekly_availability ORDER BY weekday, start_time')
+ console.log('✓ Weekly availability fetched:', Array.isArray(rows) ? rows.length : 0, 'slots')
return NextResponse.json(rows)
} catch (error) {
- console.error('Error fetching weekly availability', error)
+ console.error('❌ Error fetching weekly availability:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
export async function POST(req: Request) {
+ console.log('=== API: POST /api/availability/weekly ===')
try {
const body = await req.json()
const { weekday, start_time, end_time, note } = body
+ console.log('Request body:', { weekday, start_time, end_time, note })
+
if (weekday === undefined || !start_time || !end_time) {
+ console.log('❌ Missing required fields')
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
}
// Prevent overlapping availability for the same weekday
const [confRows] = await pool.query('SELECT COUNT(*) AS cnt FROM weekly_availability WHERE weekday = ? AND NOT (end_time <= ? OR start_time >= ?)', [weekday, start_time, end_time])
// @ts-ignore
- if (confRows[0].cnt > 0) {
+ const overlaps = confRows[0].cnt
+ console.log('Overlap check:', { overlaps })
+
+ if (overlaps > 0) {
+ console.log('❌ Overlapping availability exists')
return NextResponse.json({ error: 'Overlapping availability exists for this weekday' }, { status: 409 })
}
const id = crypto.randomUUID()
await pool.query('INSERT INTO weekly_availability SET ?', [{ id, weekday, start_time, end_time, note }])
+ console.log('✓ Weekly availability created:', id)
return NextResponse.json({ ok: true, id })
} catch (error) {
- console.error('Error creating weekly availability', error)
+ console.error('❌ Error creating weekly availability:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
export async function PUT(req: Request) {
+ console.log('=== API: PUT /api/availability/weekly ===')
try {
const body = await req.json()
const { id, weekday, start_time, end_time, note } = body
+ console.log('Request body:', { id, weekday, start_time, end_time, note })
+
if (!id || weekday === undefined || !start_time || !end_time) {
+ console.log('❌ Missing required fields')
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
}
// Check overlaps excluding the current id
const [confRows] = await pool.query('SELECT COUNT(*) AS cnt FROM weekly_availability WHERE weekday = ? AND id != ? AND NOT (end_time <= ? OR start_time >= ?)', [weekday, id, start_time, end_time])
// @ts-ignore
- if (confRows[0].cnt > 0) {
+ const overlaps = confRows[0].cnt
+ console.log('Overlap check (excluding current):', { overlaps })
+
+ if (overlaps > 0) {
+ console.log('❌ Overlapping availability exists')
return NextResponse.json({ error: 'Overlapping availability exists for this weekday' }, { status: 409 })
}
- await pool.query('UPDATE weekly_availability SET weekday = ?, start_time = ?, end_time = ?, note = ? WHERE id = ?', [weekday, start_time, end_time, note || null, id])
+ const [result] = await pool.query('UPDATE weekly_availability SET weekday = ?, start_time = ?, end_time = ?, note = ? WHERE id = ?', [weekday, start_time, end_time, note || null, id])
+ console.log('✓ Weekly availability updated:', id, 'Affected rows:', (result as any).affectedRows)
return NextResponse.json({ ok: true })
} catch (error) {
- console.error('Error updating weekly availability', error)
+ console.error('❌ Error updating weekly availability:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
export async function DELETE(req: Request) {
+ console.log('=== API: DELETE /api/availability/weekly ===')
try {
const { id } = await req.json()
- if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
- await pool.query('DELETE FROM weekly_availability WHERE id = ?', [id])
+ console.log('Request body:', { id })
+
+ if (!id) {
+ console.log('❌ Missing id')
+ return NextResponse.json({ error: 'Missing id' }, { status: 400 })
+ }
+
+ const [result] = await pool.query('DELETE FROM weekly_availability WHERE id = ?', [id])
+ console.log('✓ Weekly availability deleted:', id, 'Affected rows:', (result as any).affectedRows)
return NextResponse.json({ ok: true })
} catch (error) {
- console.error('Error deleting weekly availability', error)
+ console.error('❌ Error deleting weekly availability:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
diff --git a/src/app/api/bookings/route.ts b/src/app/api/bookings/route.ts
index c211239..bedc483 100644
--- a/src/app/api/bookings/route.ts
+++ b/src/app/api/bookings/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { pool } from '@/lib/db'
import crypto from 'crypto'
+import { sampleData } from '@/config/sample-data'
function normalizeTime(t: string) {
// Accept HH:MM or HH:MM:SS and normalize to HH:MM:SS
@@ -10,17 +11,22 @@ function normalizeTime(t: string) {
}
export async function GET(req: Request) {
+ console.log('=== API: GET /api/bookings ===')
try {
const url = new URL(req.url)
const date = url.searchParams.get('date')
+ console.log('Query params:', { date })
+
if (date) {
const [rows] = await pool.query('SELECT * FROM bookings WHERE booking_date = ? ORDER BY start_time', [date])
+ console.log('✓ Bookings fetched for date:', date, 'Count:', Array.isArray(rows) ? rows.length : 0)
return NextResponse.json(rows)
}
const [rows] = await pool.query('SELECT * FROM bookings ORDER BY booking_date DESC, start_time')
+ console.log('✓ All bookings fetched. Count:', Array.isArray(rows) ? rows.length : 0)
return NextResponse.json(rows)
} catch (error) {
- console.error('Error fetching bookings', error)
+ console.error('❌ Error fetching bookings:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
@@ -28,10 +34,68 @@ export async function GET(req: Request) {
export async function POST(req: Request) {
try {
const body = await req.json()
- const { name, email, phone, party_size, date, start_time, duration_hours, menu_id, package_id, message } = body
+ console.log('=== BOOKING REQUEST ===')
+ console.log('Request body:', JSON.stringify(body, null, 2))
+
+ // Bot protection: check timestamp (minimum 3 seconds since form load)
+ if (body._t) {
+ const timeSinceLoad = Date.now() - body._t
+ if (timeSinceLoad < 3000) {
+ console.log('🤖 Bot detected: too fast submission', timeSinceLoad, 'ms')
+ return NextResponse.json({ error: 'Please wait a moment before submitting' }, { status: 429 })
+ }
+ if (timeSinceLoad > 3600000) { // 1 hour
+ console.log('⏰ Form expired: submission too slow', timeSinceLoad, 'ms')
+ return NextResponse.json({ error: 'Form expired, please refresh and try again' }, { status: 400 })
+ }
+ }
+
+ const { name, email, phone, address, party_size, date, start_time, duration_hours, menu_id, package_id, include_service, include_cleanup, message } = body
if (!name || !email || !date || !start_time) {
+ console.log('ERROR: Missing required fields')
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
+
+ // Basic spam detection
+ const fullText = `${name} ${email} ${message || ''}`.toLowerCase()
+ const suspiciousPatterns = [
+ /viagra/i,
+ /cialis/i,
+ /casino/i,
+ /lottery/i,
+ /(https?:\/\/[^\s]+){2,}/gi, // Multiple URLs
+ ]
+
+ for (const pattern of suspiciousPatterns) {
+ if (pattern.test(fullText)) {
+ console.log('🤖 Spam pattern detected:', pattern)
+ return NextResponse.json({ error: 'Suspicious content detected' }, { status: 400 })
+ }
+ }
+
+ // Calculate price
+ let totalPrice = 0
+ const partyCovers = Number(party_size) || 0
+
+ // Find menu price
+ const menu = sampleData.menus.find(m => m.id === menu_id)
+ if (menu && partyCovers > 0) {
+ totalPrice = menu.basePricePerCover * partyCovers
+ }
+
+ // Add service cost (100 kr per cover)
+ if (include_service && partyCovers > 0) {
+ totalPrice += 100 * partyCovers
+ }
+
+ // Add cleanup cost (500 kr flat)
+ if (include_cleanup) {
+ totalPrice += 500
+ }
+
+ // Add reservation fee (500 kr flat)
+ const reservationFee = 500
+ totalPrice += reservationFee
const start = normalizeTime(start_time)
const dur = Number(duration_hours) || 3
@@ -42,34 +106,69 @@ export async function POST(req: Request) {
const endH = String(Math.floor(endMinutes / 60)).padStart(2, '0')
const endM = String(endMinutes % 60).padStart(2, '0')
const end = `${endH}:${endM}:00`
+
+ console.log('Booking time:', { date, start, end, duration: dur })
// 1) Check blocked dates
const [blocked] = await pool.query('SELECT COUNT(*) AS cnt FROM blocked_dates WHERE block_date = ?', [date])
// @ts-ignore
- if (blocked[0].cnt > 0) return NextResponse.json({ error: 'Date is blocked' }, { status: 409 })
+ console.log('Blocked dates check:', blocked[0])
+ if (blocked[0].cnt > 0) {
+ console.log('ERROR: Date is blocked')
+ return NextResponse.json({ error: 'Datoen er blokeret' }, { status: 409 })
+ }
// 2) Check there is a weekly availability that fully covers the requested interval
const bookingDate = new Date(date)
const weekday = bookingDate.getDay()
+ console.log('Checking availability for weekday:', weekday)
+
const [availRows] = await pool.query('SELECT * FROM weekly_availability WHERE weekday = ?', [weekday])
// @ts-ignore
const availArr = Array.isArray(availRows) ? availRows : []
+ console.log('Available slots for weekday:', availArr)
+
const covers = availArr.some((a: any) => (a.start_time <= start && a.end_time >= end))
+ console.log('Time slot covered by availability:', covers)
+
if (!covers) {
- return NextResponse.json({ error: 'Requested time is outside of availability' }, { status: 409 })
+ console.log('ERROR: Requested time is outside of availability')
+ return NextResponse.json({ error: 'Det valgte tidspunkt er udenfor åbningstid' }, { status: 409 })
}
// 3) Check for overlapping bookings on same date
const [overlapRows] = await pool.query('SELECT COUNT(*) AS cnt FROM bookings WHERE booking_date = ? AND NOT (end_time <= ? OR start_time >= ?)', [date, start, end])
// @ts-ignore
+ console.log('Overlapping bookings check:', overlapRows[0])
if (overlapRows[0].cnt > 0) {
- return NextResponse.json({ error: 'Time slot already booked' }, { status: 409 })
+ console.log('ERROR: Time slot already booked')
+ return NextResponse.json({ error: 'Tidspunktet er allerede booket' }, { status: 409 })
}
const id = crypto.randomUUID()
- await pool.query('INSERT INTO bookings SET ?', [{ id, name, email, phone: phone || null, party_size: party_size || null, booking_date: date, start_time: start, end_time: end, menu_id: menu_id || null, package_id: package_id || null, message: message || null }])
+ await pool.query('INSERT INTO bookings SET ?', [{
+ id,
+ name,
+ email,
+ phone: phone || null,
+ address: address || null,
+ party_size: party_size || null,
+ booking_date: date,
+ start_time: start,
+ end_time: end,
+ menu_id: menu_id || null,
+ package_id: package_id || null,
+ include_service: include_service || false,
+ include_cleanup: include_cleanup || false,
+ total_price: totalPrice > 0 ? totalPrice : null,
+ message: message || null
+ }])
- return NextResponse.json({ ok: true, id })
+ console.log('=== BOOKING SUCCESS ===')
+ console.log('Booking ID:', id)
+ console.log('Total price:', totalPrice)
+
+ return NextResponse.json({ ok: true, id, total_price: totalPrice })
} catch (error) {
console.error('Error creating booking', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
@@ -77,22 +176,31 @@ export async function POST(req: Request) {
}
export async function DELETE(req: Request) {
+ console.log('=== API: DELETE /api/bookings ===')
try {
const body = await req.json()
const { id } = body
+ console.log('Request body:', { id })
+
if (!id) {
+ console.log('❌ Missing booking id')
return NextResponse.json({ error: 'Missing booking id' }, { status: 400 })
}
const [result] = await pool.query('DELETE FROM bookings WHERE id = ?', [id])
// @ts-ignore
- if (result.affectedRows === 0) {
+ const affectedRows = result.affectedRows
+ console.log('Delete result - Affected rows:', affectedRows)
+
+ if (affectedRows === 0) {
+ console.log('❌ Booking not found:', id)
return NextResponse.json({ error: 'Booking not found' }, { status: 404 })
}
+ console.log('✓ Booking deleted successfully:', id)
return NextResponse.json({ ok: true })
} catch (error) {
- console.error('Error deleting booking', error)
+ console.error('❌ Error deleting booking:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts
index d1b07e9..2c32da8 100644
--- a/src/app/api/contact/route.ts
+++ b/src/app/api/contact/route.ts
@@ -6,14 +6,60 @@ function validate(body: any) {
if (!body) return 'No data'
const { name, email, phone, message } = body
if (!name || !email || !message) return 'Missing required fields'
+
+ // Basic spam detection
+ const suspiciousPatterns = [
+ /viagra/i,
+ /cialis/i,
+ /casino/i,
+ /lottery/i,
+ /crypto/i,
+ /bitcoin/i,
+ /(https?:\/\/[^\s]+){3,}/gi, // Multiple URLs
+ ]
+
+ const fullText = `${name} ${email} ${message}`.toLowerCase()
+ for (const pattern of suspiciousPatterns) {
+ if (pattern.test(fullText)) {
+ console.log('🤖 Spam pattern detected:', pattern)
+ return 'Suspicious content detected'
+ }
+ }
+
return null
}
export async function POST(req: Request) {
+ console.log('=== API: POST /api/contact ===')
try {
const body = await req.json()
+ console.log('Contact form submission:', {
+ name: body.name,
+ email: body.email,
+ hasPhone: !!body.phone,
+ hasAddress: !!body.address,
+ messageLength: body.message?.length,
+ hasTimestamp: !!body._t
+ })
+
+ // Bot protection: check timestamp (minimum 2 seconds since form load)
+ if (body._t) {
+ const timeSinceLoad = Date.now() - body._t
+ if (timeSinceLoad < 2000) {
+ console.log('🤖 Bot detected: too fast submission', timeSinceLoad, 'ms')
+ return NextResponse.json({ error: 'Please wait a moment before submitting' }, { status: 429 })
+ }
+ if (timeSinceLoad > 3600000) { // 1 hour
+ console.log('⏰ Form expired: submission too slow', timeSinceLoad, 'ms')
+ return NextResponse.json({ error: 'Form expired, please refresh and try again' }, { status: 400 })
+ }
+ }
+
const err = validate(body)
- if (err) return NextResponse.json({ error: err }, { status: 400 })
+ if (err) {
+ console.log('❌ Validation error:', err)
+ return NextResponse.json({ error: err }, { status: 400 })
+ }
// Read SMTP config from environment
const host = process.env.SMTP_HOST || 'smtp.office365.com'
@@ -21,9 +67,11 @@ export async function POST(req: Request) {
const user = process.env.SMTP_USER
const pass = process.env.SMTP_PASS
const to = process.env.BOOKING_EMAIL || 'christian@warme.dk'
+
+ console.log('SMTP config:', { host, port, user: user ? '***' : 'not set', to })
if (!user || !pass) {
- console.error('SMTP credentials not configured')
+ console.error('❌ SMTP credentials not configured')
return NextResponse.json({ error: 'Email service not configured' }, { status: 500 })
}
@@ -54,6 +102,7 @@ export async function POST(req: Request) {
// as the From, and set Reply-To to the form submitter so replies go to them.
const senderEmail = process.env.SENDER_EMAIL || user
+ console.log('Sending email...', { from: senderEmail, to, subject })
await transporter.sendMail({
from: senderEmail,
replyTo: `${name} <${email}>`,
@@ -63,9 +112,10 @@ export async function POST(req: Request) {
text: `${name} (${email})\n\n${message}`
})
+ console.log('✓ Contact email sent successfully')
return NextResponse.json({ ok: true })
} catch (error) {
- console.error('Error in contact POST:', error)
+ console.error('❌ Error in contact POST:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
diff --git a/src/app/api/menus/route.ts b/src/app/api/menus/route.ts
index 3e33d96..15868f3 100644
--- a/src/app/api/menus/route.ts
+++ b/src/app/api/menus/route.ts
@@ -3,53 +3,66 @@ import { pool, rowToMenu, menuToRow } from '@/lib/db';
import { Menu } from '@/types/models';
export async function GET() {
+ console.log('=== API: GET /api/menus ===')
try {
const [rows] = await pool.query('SELECT * FROM menus ORDER BY created_at DESC');
const menus = (rows as any[]).map(row => rowToMenu(row));
+ console.log('✓ Menus fetched:', menus.length)
return NextResponse.json(menus);
} catch (error) {
- console.error('Error fetching menus:', error);
+ console.error('❌ Error fetching menus:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function POST(req: Request) {
+ console.log('=== API: POST /api/menus ===')
try {
const menu = await req.json();
+ console.log('Request body:', { id: menu.id, name: menu.name })
+
const row = menuToRow(menu);
await pool.query('INSERT INTO menus SET ?', row);
+ console.log('✓ Menu created successfully:', menu.id)
return NextResponse.json({ message: 'Menu created successfully' });
} catch (error) {
- console.error('Error creating menu:', error);
+ console.error('❌ Error creating menu:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function PUT(req: Request) {
+ console.log('=== API: PUT /api/menus ===')
try {
const menu = await req.json();
+ console.log('Request body:', { id: menu.id, name: menu.name })
+
const row = menuToRow(menu);
- await pool.query('UPDATE menus SET ? WHERE id = ?', [row, menu.id]);
+ const [result] = await pool.query('UPDATE menus SET ? WHERE id = ?', [row, menu.id]);
+ console.log('✓ Menu updated successfully:', menu.id, 'Affected rows:', (result as any).affectedRows)
return NextResponse.json({ message: 'Menu updated successfully' });
} catch (error) {
- console.error('Error updating menu:', error);
+ console.error('❌ Error updating menu:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(req: Request) {
+ console.log('=== API: DELETE /api/menus ===')
try {
const { id } = await req.json();
+ console.log('Request body:', { id })
- await pool.query('DELETE FROM menus WHERE id = ?', [id]);
+ const [result] = await pool.query('DELETE FROM menus WHERE id = ?', [id]);
+ console.log('✓ Menu deleted successfully:', id, 'Affected rows:', (result as any).affectedRows)
return NextResponse.json({ message: 'Menu deleted successfully' });
} catch (error) {
- console.error('Error deleting menu:', error);
+ console.error('❌ Error deleting menu:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
\ No newline at end of file
diff --git a/src/app/book/page.tsx b/src/app/book/page.tsx
index 9e0e379..7cae559 100644
--- a/src/app/book/page.tsx
+++ b/src/app/book/page.tsx
@@ -54,12 +54,28 @@ export default function BookingPage() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
+ const [address, setAddress] = useState('')
const [partySize, setPartySize] = useState
(undefined)
const [message, setMessage] = useState('')
+ const [includeService, setIncludeService] = useState(false)
+ const [includeCleanup, setIncludeCleanup] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [statusMsg, setStatusMsg] = useState(null)
+ const [bookingSuccess, setBookingSuccess] = useState<{
+ id: string
+ date: string
+ time: string
+ menuName: string
+ covers: number
+ totalPrice: number
+ reservationFee: number
+ } | null>(null)
const [selectedMenu, setSelectedMenu] = useState(null)
const [selectedPackage, setSelectedPackage] = useState(null)
+
+ // Bot protection
+ const [honeypot, setHoneypot] = useState('')
+ const [formLoadTime, setFormLoadTime] = useState(0)
// Handle URL parameters
useEffect(() => {
@@ -75,6 +91,10 @@ export default function BookingPage() {
}
}
+ if (locationParam) {
+ setAddress(locationParam)
+ }
+
if (guestsParam) {
const guests = parseInt(guestsParam)
if (!isNaN(guests)) {
@@ -84,17 +104,24 @@ export default function BookingPage() {
}, [])
useEffect(() => {
+ console.log('=== Loading availability data ===')
let mounted = true
+
+ // Set form load time for bot protection
+ setFormLoadTime(Date.now())
+
Promise.all([
fetch('/api/availability/weekly').then(r => r.ok ? r.json() : []),
fetch('/api/availability/blocked').then(r => r.ok ? r.json() : [])
]).then(([w, b]) => {
if (!mounted) return
+ console.log('Weekly availability loaded:', Array.isArray(w) ? w.length : 0, 'slots')
+ console.log('Blocked dates loaded:', Array.isArray(b) ? b.length : 0, 'dates')
setWeekly(Array.isArray(w) ? w : [])
setBlockedDates(Array.isArray(b) ? b.map((x: any) => x.block_date) : [])
setLoadingAvail(false)
}).catch(err => {
- console.error('Error loading availability', err)
+ console.error('❌ Error loading availability:', err)
setLoadingAvail(false)
})
return () => { mounted = false }
@@ -130,21 +157,105 @@ export default function BookingPage() {
return false
}
- // update available slots when date or availability or duration changes
+ // Update time slots whenever date or duration changes
useEffect(() => {
if (!selectedDate) {
setSlots([])
setSelectedSlot(null)
return
}
- const s = generateSlotsForDate(weekly, selectedDate, durationHours)
- setSlots(s)
- setSelectedSlot(s.length > 0 ? s[0] : null)
- }, [selectedDate, weekly, durationHours])
-
+ console.log('=== Generating time slots ===')
+ console.log('Date:', selectedDate.toISOString().split('T')[0], 'Duration:', durationHours, 'hours')
+ const newSlots = generateSlotsForDate(weekly, selectedDate, durationHours)
+ console.log('Available slots:', newSlots.length, newSlots)
+ setSlots(newSlots)
+ setSelectedSlot(null)
+ }, [selectedDate, durationHours, weekly])
+
return (
+ {/* Success message */}
+ {bookingSuccess ? (
+
+
+
+
Tak for din forespørgsel!
+
Vi kontakter dig hurtigst muligt for at bekræfte din booking
+
+
+
+
Ordre nummer
+
{bookingSuccess.id.substring(0, 8).toUpperCase()}
+
+
Dato: {bookingSuccess.date}
+
Tidspunkt: {bookingSuccess.time}
+
Menu: {bookingSuccess.menuName}
+
Kuverter: {bookingSuccess.covers}
+
+
+
+ {/* Price breakdown on confirmation */}
+
+
Pris oversigt
+
+
+ Total pris
+ {bookingSuccess.totalPrice.toLocaleString('da-DK')} kr
+
+
+ Reservationsgebyr (betales nu)
+ {bookingSuccess.reservationFee.toLocaleString('da-DK')} kr
+
+
+
+ Resterende betaling
+ {(bookingSuccess.totalPrice - bookingSuccess.reservationFee).toLocaleString('da-DK')} kr
+
+
+
+
+ Reservationsgebyret på {bookingSuccess.reservationFee} kr betales ved bekræftelse og trækkes fra den endelige pris.
+
+
+
+
+ Du vil modtage en bekræftelse på email med betalingsinformation. Gem dit ordre nummer til reference.
+
+
+
+
{
+ setBookingSuccess(null)
+ setSelectedDate(undefined)
+ setSelectedSlot(null)
+ setName('')
+ setEmail('')
+ setPhone('')
+ setAddress('')
+ setPartySize(undefined)
+ setMessage('')
+ setSelectedMenu(null)
+ setSelectedPackage(null)
+ }}
+ className="px-6 py-2 bg-accent text-white rounded-md hover:bg-accent-600 transition-colors"
+ >
+ Book en ny dato
+
+
+ Tilbage til forsiden
+
+
+
+ ) : (
+ <>
Forespørg booking
@@ -160,6 +271,8 @@ export default function BookingPage() {
type="text"
placeholder="By eller postnummer"
className="w-full"
+ value={address}
+ onChange={(e) => setAddress(e.target.value)}
/>
@@ -262,9 +375,31 @@ export default function BookingPage() {
onChange={() => setSelectedMenu(menu.id)}
className="mt-1"
/>
-
-
{menu.title}
-
{menu.description}
+
+
{menu.title}
+
{menu.description}
+
+ {/* Full course list */}
+
+
Menuen indeholder:
+
+ {menu.courses.map((course, courseIdx) => (
+
+ •
+ {course}
+
+ ))}
+
+
+
+
+ {menu.tags.map((tag, tagIdx) => (
+
+ {tag}
+
+ ))}
+
+
{menu.basePricePerCover} kr pr. kuvert
@@ -288,9 +423,29 @@ export default function BookingPage() {
onChange={() => setSelectedPackage(pkg.id)}
className="mt-1"
/>
-
-
{pkg.name}
-
{pkg.notes}
+
+
{pkg.name}
+
{pkg.notes}
+
+ {/* Package includes */}
+
+
Pakken inkluderer:
+
+ {pkg.includes.map((item, itemIdx) => (
+
+ ✓
+ {item}
+
+ ))}
+
+
+
+
+ {pkg.pricePerCover} kr pr. kuvert
+
+
+ Min. {pkg.minCovers} personer • Maks. {pkg.maxCovers} personer
+
))}
@@ -332,21 +487,162 @@ export default function BookingPage() {
className="w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm"
>
+
+ {/* Honeypot field - hidden from users, but visible to bots */}
+
+ Website (lad stå tom)
+ setHoneypot(e.target.value)}
+ tabIndex={-1}
+ autoComplete="off"
+ />
+
+ {/* Step 4: Additional Services */}
+
+ Tilvalg
+
+
+ setIncludeService(e.target.checked)}
+ className="mt-1"
+ />
+
+
Professionel servering
+
Inkluderer servering af alle retter med professionel servitør
+
+100 kr pr. kuvert
+
+
+
+
+ setIncludeCleanup(e.target.checked)}
+ className="mt-1"
+ />
+
+
Fuld oprydning
+
Komplet oprydning af køkken og service efter eventet
+
+500 kr fast pris
+
+
+
+
+
+ {/* Price Summary */}
+ {selectedMenu && partySize && (
+
+ Pris oversigt
+
+ {(() => {
+ const menu = sampleData.menus.find((m: Menu) => m.id === selectedMenu)
+ const menuPrice = menu ? menu.basePricePerCover : 0
+ const covers = partySize || 0
+ const menuTotal = menuPrice * covers
+ const serviceTotal = includeService ? 100 * covers : 0
+ const cleanupTotal = includeCleanup ? 500 : 0
+ const reservationFee = 500
+ const subtotal = menuTotal + serviceTotal + cleanupTotal
+ const total = subtotal + reservationFee
+
+ return (
+ <>
+
+
+ {menu?.title || 'Menu'} ({covers} kuverter × {menuPrice} kr)
+
+ {menuTotal.toLocaleString('da-DK')} kr
+
+
+ {includeService && (
+
+
+ Professionel servering ({covers} kuverter × 100 kr)
+
+ {serviceTotal.toLocaleString('da-DK')} kr
+
+ )}
+
+ {includeCleanup && (
+
+ Fuld oprydning
+ 500 kr
+
+ )}
+
+
+
+ Subtotal
+ {subtotal.toLocaleString('da-DK')} kr
+
+
+
+
+ Reservationsgebyr
+ 500 kr
+
+
+
+
+ Total
+ {total.toLocaleString('da-DK')} kr
+
+
+
+
+ Bemærk: Reservationsgebyret på 500 kr betales ved booking og trækkes fra den endelige pris.
+
+ >
+ )
+ })()}
+
+
+ )}
+
- {statusMsg &&
{statusMsg}
}
+ {statusMsg && (
+
+ )}
{
+ console.log('=== Booking Submit ===')
setStatusMsg(null)
+
+ // Bot protection: check honeypot
+ if (honeypot) {
+ console.log('🤖 Bot detected: honeypot filled')
+ setStatusMsg('Der skete en fejl. Prøv igen.')
+ return
+ }
+
+ // Bot protection: check submission time (minimum 3 seconds for booking)
+ const timeSinceLoad = Date.now() - formLoadTime
+ if (timeSinceLoad < 3000) {
+ console.log('🤖 Bot detected: too fast submission', timeSinceLoad, 'ms')
+ setStatusMsg('Vent venligst et øjeblik før du sender.')
+ return
+ }
+
if (!selectedDate || !selectedSlot) {
+ console.log('❌ Missing date or time slot')
setStatusMsg('Vælg venligst en dato og et tidspunkt')
return
}
if (!name || !email) {
+ console.log('❌ Missing name or email')
setStatusMsg('Indtast navn og email')
return
}
@@ -360,34 +656,59 @@ export default function BookingPage() {
name,
email,
phone,
+ address,
party_size: partySize,
date: iso,
start_time: selectedSlot,
duration_hours: durationHours,
menu_id: selectedMenu,
package_id: selectedPackage,
+ include_service: includeService,
+ include_cleanup: includeCleanup,
message,
+ _t: formLoadTime, // Bot protection timestamp
}
+ console.log('Booking payload:', { ...payload, party_size: payload.party_size })
+
const res = await fetch('/api/bookings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
+ console.log('Response status:', res.status)
+
if (res.ok) {
- setStatusMsg('Booking forespørgsel modtaget — vi kontakter dig for bekræftelse.')
- // clear form
- setSelectedDate(undefined)
- setSelectedSlot(null)
- setName('')
- setEmail('')
- setPhone('')
- setPartySize(undefined)
- setMessage('')
+ const data = await res.json()
+ console.log('✓ Booking successful:', data.id)
+ const formattedDate = `${d}/${m}/${y}`
+
+ // Calculate price for confirmation
+ const menu = sampleData.menus.find((m: Menu) => m.id === selectedMenu)
+ const menuPrice = menu ? menu.basePricePerCover : 0
+ const covers = partySize || 0
+ const menuTotal = menuPrice * covers
+ const serviceTotal = includeService ? 100 * covers : 0
+ const cleanupTotal = includeCleanup ? 500 : 0
+ const reservationFee = 500
+ const total = menuTotal + serviceTotal + cleanupTotal + reservationFee
+
+ setBookingSuccess({
+ id: data.id,
+ date: formattedDate,
+ time: selectedSlot,
+ menuName: menu?.title || 'Valgt menu',
+ covers: covers,
+ totalPrice: total,
+ reservationFee: reservationFee
+ })
+ setStatusMsg(null)
} else if (res.status === 409) {
const j = await res.json()
- setStatusMsg(j?.error || 'Konflikt ved booking (optaget eller udenfor åbning)')
+ console.log('❌ Booking conflict (409):', j?.error)
+ setStatusMsg(j?.error || 'Tidspunktet er ikke tilgængeligt')
} else {
const j = await res.json()
+ console.log('❌ Booking error:', res.status, j?.error)
setStatusMsg(j?.error || 'Fejl ved afsendelse')
}
} catch (err) {
- console.error(err)
+ console.error('❌ Booking exception:', err)
setStatusMsg('Fejl ved forbindelse')
} finally {
setSubmitting(false)
@@ -399,6 +720,8 @@ export default function BookingPage() {
+ >
+ )}
)
diff --git a/src/app/globals.css b/src/app/globals.css
index 2f7fca5..bc8332f 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -218,87 +218,59 @@ body {
box-shadow: none !important;
}
-/* Cook calendar: large, simple interface for easy use */
+/* Cook calendar: simple colors only */
.cook-calendar {
width: 100%;
- font-size: 1.1rem;
}
-.cook-calendar .rdp {
- width: 100%;
- margin: 0 auto;
+/* Default day styling - green for available */
+.cook-calendar .rdp-day {
+ background: #f0fdf4 !important; /* green-50 */
+ color: #15803d !important; /* green-700 */
+ border: 2px solid #bbf7d0 !important; /* green-200 */
+ font-weight: 600 !important;
}
-.cook-calendar .rdp-table {
- width: 100%;
- table-layout: fixed;
+.cook-calendar .rdp-day:hover {
+ background: #dcfce7 !important; /* green-100 */
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
-.cook-calendar .rdp-day,
-.cook-calendar .rdp-day_disabled {
- width: 100%;
- min-height: 80px;
- padding: 15px;
- font-size: 1.2rem;
- font-weight: 600;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 12px;
- transition: all 0.2s ease;
- cursor: pointer !important;
- background: #f0fdf4; /* green-50 */
- color: #15803d; /* green-700 */
- border: 2px solid #bbf7d0; /* green-200 */
- user-select: none;
-}
-
-.cook-calendar .rdp-day:hover:not(.rdp-day_disabled) {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
-}
-
-.cook-calendar .rdp-day_disabled {
- opacity: 0.3;
- cursor: not-allowed;
- transform: none !important;
+/* Today styling - override default today color */
+.cook-calendar .rdp-day_today {
+ background: #f0fdf4 !important; /* green-50 same as default */
+ color: #15803d !important; /* green-700 */
+ border: 2px solid #bbf7d0 !important; /* green-200 */
}
+/* Blocked days - red (must come after today to override) */
.cook-calendar .day-blocked,
.cook-calendar .rdp-day_blocked {
background: #dc2626 !important; /* red-600 */
color: white !important;
border-color: #991b1b !important; /* red-800 */
- cursor: pointer !important;
}
-.cook-calendar .rdp-caption {
- font-size: 1.5rem;
- font-weight: 700;
- margin-bottom: 1rem;
+/* Blocked today - also red */
+.cook-calendar .rdp-day_today.day-blocked,
+.cook-calendar .rdp-day_today.rdp-day_blocked {
+ background: #dc2626 !important; /* red-600 */
+ color: white !important;
+ border-color: #991b1b !important; /* red-800 */
}
-.cook-calendar .rdp-weekday {
- font-size: 1rem;
- font-weight: 600;
- color: #374151; /* gray-700 */
- padding: 10px 0;
+.cook-calendar .day-blocked:hover,
+.cook-calendar .rdp-day_blocked:hover {
+ background: #b91c1c !important; /* red-700 */
}
-@media (min-width: 640px) {
- .cook-calendar .rdp-day,
- .cook-calendar .rdp-day_disabled {
- min-height: 100px;
- font-size: 1.3rem;
- }
-}
-
-@media (min-width: 1024px) {
- .cook-calendar .rdp-day,
- .cook-calendar .rdp-day_disabled {
- min-height: 120px;
- font-size: 1.4rem;
- }
+/* Outside days (previous/next month) - muted */
+.cook-calendar .rdp-day_outside {
+ background: #f9fafb !important; /* gray-50 */
+ color: #9ca3af !important; /* gray-400 */
+ border-color: #e5e7eb !important; /* gray-200 */
+ opacity: 0.5 !important;
}
/* Admin-specific calendar colors */
@@ -340,10 +312,11 @@ body {
/* Date picker popup styling */
.rdp {
- --rdp-cell-size: 36px;
+ --rdp-cell-size: 40px;
--rdp-accent-color: #f59e0b;
--rdp-background-color: #ffffff;
margin: 0;
+ width: 100%;
}
.rdp-months {
@@ -358,56 +331,78 @@ body {
.rdp-table {
width: 100%;
border-collapse: separate;
- border-spacing: 2px;
+ border-spacing: 4px;
+ table-layout: fixed;
}
.rdp-head_cell,
.rdp-cell {
width: var(--rdp-cell-size);
height: var(--rdp-cell-size);
-}
-
-.rdp-head_cell {
- font-weight: 500;
- font-size: 0.75rem;
- color: #6b7280;
text-align: center;
}
-.rdp-day {
+.rdp-row {
+ display: flex;
width: 100%;
- height: 100%;
- border: none;
- background: none;
+}
+
+.rdp-head_cell {
+ font-weight: 600;
+ font-size: 0.8rem;
+ color: #374151;
+ text-align: center;
+}
+
+.rdp-day_button {
+ width: 2rem;
+ height: 2rem;
+ border-radius: 0.375rem;
+ border: 1px solid #e5e7eb;
font-size: 0.875rem;
- border-radius: 6px;
- transition: all 0.15s ease;
+ font-weight: 500;
+ background: white;
+ color: #374151;
+ cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
- cursor: pointer;
+ transition: all 0.2s ease;
}
.rdp-day:hover:not(.rdp-day_disabled) {
- background-color: #f3f4f6;
+ background-color: #f59e0b;
+ color: white;
+ border-color: #f59e0b;
transform: scale(1.05);
}
.rdp-day_selected {
background-color: var(--rdp-accent-color) !important;
color: white !important;
- font-weight: 500;
+ border-color: var(--rdp-accent-color) !important;
+ font-weight: 600;
}
.rdp-day_today:not(.rdp-day_selected) {
- background-color: #e5e7eb;
- font-weight: 500;
+ background-color: #3b82f6;
+ color: white;
+ border-color: #3b82f6;
+ font-weight: 600;
}
.rdp-day_disabled {
- color: #d1d5db;
- cursor: not-allowed;
- opacity: 0.5;
+ background-color: #f3f4f6 !important;
+ color: #9ca3af !important;
+ border-color: #e5e7eb !important;
+ cursor: not-allowed !important;
+ opacity: 0.6 !important;
+}
+
+.rdp-day_disabled:hover {
+ background-color: #f3f4f6 !important;
+ color: #9ca3af !important;
+ transform: none !important;
}
.rdp-day_outside {
@@ -418,9 +413,12 @@ body {
display: flex;
justify-content: center;
align-items: center;
- padding: 8px 0 16px 0;
- font-weight: 600;
- font-size: 0.95rem;
+ padding: 8px 0 20px 0;
+ font-weight: 700;
+ font-size: 1.1rem;
+ position: relative;
+ width: 100%;
+ color: #1f2937;
}
.rdp-nav {
diff --git a/src/app/kontakt/page.tsx b/src/app/kontakt/page.tsx
index 6a399a9..60ca7ee 100644
--- a/src/app/kontakt/page.tsx
+++ b/src/app/kontakt/page.tsx
@@ -6,54 +6,67 @@ export default function ContactPage() {
const { contact } = sampleData.chef
return (
-
-
-
Kontakt
-
-
- {/* Contact Info */}
-
-
Kontakt information
-
+
+
+
+
- {/* Contact Form */}
-
-
Send besked
-
+
+ {/* Contact Info */}
+
+
Kontakt information
+
+
+
+ {/* Contact Form */}
+
+
Send besked
+
+
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
new file mode 100644
index 0000000..b9c1ce7
--- /dev/null
+++ b/src/app/login/page.tsx
@@ -0,0 +1,145 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+
+export default function LoginPage() {
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const router = useRouter()
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setLoading(true)
+ setError('')
+
+ try {
+ console.log('=== LOGIN ATTEMPT START ===')
+ console.log('Username:', username)
+ console.log('Password length:', password.length)
+ console.log('Timestamp:', new Date().toISOString())
+
+ const response = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({ username, password }),
+ })
+
+ console.log('=== LOGIN RESPONSE ===')
+ console.log('Response status:', response.status)
+ console.log('Response ok:', response.ok)
+ console.log('Response headers:', Object.fromEntries(response.headers.entries()))
+
+ if (response.ok) {
+ const data = await response.json()
+ console.log('=== LOGIN SUCCESS ===')
+ console.log('Got token:', !!data.token)
+ console.log('Token length:', data.token?.length)
+ console.log('User data:', data.user)
+
+ // Check cookies
+ console.log('Document cookies:', document.cookie)
+
+ // Store session token (backup, but we use HTTP-only cookie)
+ localStorage.setItem('authToken', data.token)
+ console.log('Stored token in localStorage')
+
+ console.log('Redirecting to /admin with full page reload...')
+ // Use window.location for full page reload to ensure cookie is sent
+ window.location.href = '/admin'
+ console.log('=== REDIRECT TRIGGERED ===')
+ } else {
+ const errorText = await response.text()
+ console.log('=== LOGIN FAILED ===')
+ console.log('Error response:', errorText)
+ try {
+ const errorData = JSON.parse(errorText)
+ setError(errorData.error || 'Login fejlede')
+ console.log('Error message set:', errorData.error)
+ } catch {
+ setError('Login fejlede - server error')
+ console.log('Could not parse error response')
+ }
+ }
+ } catch (err) {
+ console.error('=== LOGIN EXCEPTION ===')
+ console.error('Error:', err)
+ console.error('Error type:', typeof err)
+ console.error('Error message:', err instanceof Error ? err.message : String(err))
+ setError('Der skete en fejl - prøv igen')
+ } finally {
+ setLoading(false)
+ console.log('=== LOGIN ATTEMPT END ===')
+ }
+ }
+
+ return (
+
+
+
+
+ Log ind
+
+
+ Admin adgang til warme.dk
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/app/menuer/page.tsx b/src/app/menuer/page.tsx
index a163a66..4448a23 100644
--- a/src/app/menuer/page.tsx
+++ b/src/app/menuer/page.tsx
@@ -44,10 +44,19 @@ export default function MenusPage() {
{/* Filters */}
diff --git a/src/app/om-christian/page.tsx b/src/app/om-christian/page.tsx
new file mode 100644
index 0000000..75c6e4b
--- /dev/null
+++ b/src/app/om-christian/page.tsx
@@ -0,0 +1,248 @@
+'use client'
+
+import Image from 'next/image'
+import { ChefHat, Award, Briefcase, Heart, Mail, Phone } from 'lucide-react'
+
+export default function ChristianProfilePage() {
+ return (
+
+ {/* Hero Section */}
+
+
+
+
+
+ {/* Profile Image */}
+
+
+ {/* Intro Text */}
+
+
Christian Wärme
+
Kok & Konditor med passion for madoplevelser
+
+ Med en unik baggrund som både udlært konditor og kok med sølvmedalje,
+ kombinerer jeg klassisk håndværk med moderne madlavning.
+ Min erfaring som tjener giver mig en dyb forståelse for gæsteoplevelsen
+ – fra køkken til bord.
+
+
+
+
+
+
+
+
+ {/* Kompetencer */}
+
+
+
+
Kompetencer & Uddannelse
+
+
+
+
+
+
Udlært Kok
+
Sølvmedalje
+
+ Sølvmedalje for fremragende håndværk og dedikation.
+
+
+
+
+
+
Udlært Konditor
+
Patissier
+
+ Omfattende konditoruddannelse med ekspertise i klassiske og moderne desserter,
+ brød og finere bagte produkter.
+
+
+
+
+
+
+
+
Erfaren Tjener
+
Service & Gæsteoplevelse
+
+ Dyb forståelse for gæsteoplevelsen fra salen.
+ Jeg ved hvad der skal til for at skabe mindeværdige øjeblikke.
+
+
+
+
+
+
+
+ {/* Min filosofi */}
+
+
+
+
Min madfilosofi
+
+
+ Med baggrund som både konditor og kok, ser jeg mad som et håndværk hvor detaljer betyder alt.
+ Min tid som tjener har lært mig, at den bedste mad ikke kun smager godt – den skaber oplevelser
+ og mindeværdige øjeblikke omkring bordet.
+
+
+ Jeg brænder for at lave mad der både ser smuk ud og smager fantastisk.
+ Fra klassiske danske traditioner til moderne fortolkninger –
+ altid med respekt for råvarerne og gæsternes forventninger.
+
+
+
+
+
+
+ {/* Erhvervserfaring */}
+
+
+
+
Erhvervserfaring
+
+ {/* Current Position */}
+
+
+
+
+
+
Kok
+ Nuværende
+
+
+
+ eVenues Catering
+
+
+
+ Professionel catering til events og selskaber. Ansvarlig for menuudvikling,
+ produktion og leverance af høj kvalitet til private og erhverv.
+
+
+
+
+
+
+
+
+
+
Kok
+
The Market Italian
+
+ Arbejdet med italiensk køkken og autentiske retter.
+ Fokus på friske råvarer og traditionelle teknikker.
+
+
+
+
+
+
+
+
+
+
Konditor
+
Konditori Antoinette
+
+ Finere konditorarbejde med klassiske franske teknikker.
+ Produktion af kager, desserter og brød til høje standarder.
+
+
+
+
+
+
+
+
+
+
Tjener
+
Strandmølle Kroen
+
+ Erfaring med gæstehåndtering og service i et klassisk kro-miljø.
+ Udviklet stærk forståelse for gæsteoplevelsen fra salen.
+
+
+
+
+
+
+
+
+
+ {/* Call to Action */}
+
+
+
+
Klar til at skabe din næste madoplevelse?
+
+ Lad os tale om hvordan jeg kan hjælpe med at gøre dit arrangement uforglemmelig
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/admin/logout-button.tsx b/src/components/admin/logout-button.tsx
new file mode 100644
index 0000000..7d6924e
--- /dev/null
+++ b/src/components/admin/logout-button.tsx
@@ -0,0 +1,31 @@
+'use client'
+
+export function LogoutButton() {
+ const handleLogout = () => {
+ // Clear local storage
+ localStorage.removeItem('authToken');
+
+ // Call logout API to clear cookie
+ fetch('/api/auth/logout', {
+ method: 'POST',
+ credentials: 'include'
+ }).then(() => {
+ // Redirect to home page
+ window.location.href = '/';
+ }).catch(() => {
+ // Even if API fails, redirect anyway
+ window.location.href = '/';
+ });
+ };
+
+ return (
+
+
+ Log ud
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/contact-form.tsx b/src/components/contact-form.tsx
index 1e7b474..49b6439 100644
--- a/src/components/contact-form.tsx
+++ b/src/components/contact-form.tsx
@@ -1,38 +1,80 @@
'use client'
-import { useState } from 'react'
+import { useState, useEffect } from 'react'
import { Input } from '@/components/ui/input'
export default function ContactForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
+ const [address, setAddress] = useState('')
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState
(null)
const [error, setError] = useState(null)
+
+ // Bot protection: honeypot and timestamp
+ const [honeypot, setHoneypot] = useState('')
+ const [formLoadTime, setFormLoadTime] = useState(0)
+
+ useEffect(() => {
+ // Record when the form was loaded
+ setFormLoadTime(Date.now())
+ }, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
+ console.log('=== Contact Form Submit ===')
setLoading(true)
setError(null)
setSuccess(null)
try {
+ // Bot protection: check honeypot
+ if (honeypot) {
+ console.log('🤖 Bot detected: honeypot filled')
+ setError('Der skete en fejl. Prøv igen.')
+ setLoading(false)
+ return
+ }
+
+ // Bot protection: check submission time (minimum 2 seconds)
+ const timeSinceLoad = Date.now() - formLoadTime
+ if (timeSinceLoad < 2000) {
+ console.log('🤖 Bot detected: too fast submission', timeSinceLoad, 'ms')
+ setError('Vent venligst et øjeblik før du sender.')
+ setLoading(false)
+ return
+ }
+
+ console.log('Submitting contact form:', { name, email, hasPhone: !!phone, hasAddress: !!address, timeSinceLoad })
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name, email, phone, message })
+ body: JSON.stringify({
+ name,
+ email,
+ phone,
+ address,
+ message,
+ _t: formLoadTime // Send timestamp for server-side validation
+ })
})
const data = await res.json()
+ console.log('Response:', { status: res.status, data })
+
if (!res.ok) throw new Error(data?.error || 'Unknown error')
+
+ console.log('✓ Contact form submitted successfully')
setSuccess('Besked sendt — tak!')
setName('')
setEmail('')
setPhone('')
+ setAddress('')
setMessage('')
} catch (err: any) {
+ console.error('❌ Contact form error:', err)
setError(err.message || 'Der skete en fejl')
} finally {
setLoading(false)
@@ -63,6 +105,25 @@ export default function ContactForm() {
setPhone(e.target.value)} className="w-full" />
+
+ Adresse
+ setAddress(e.target.value)} className="w-full" placeholder="By eller postnummer" />
+
+
+ {/* Honeypot field - hidden from users, but visible to bots */}
+
+ Website (lad stå tom)
+ setHoneypot(e.target.value)}
+ tabIndex={-1}
+ autoComplete="off"
+ />
+
+
)
diff --git a/src/components/marketing/hero.tsx b/src/components/marketing/hero.tsx
index c601cf2..f84f26b 100644
--- a/src/components/marketing/hero.tsx
+++ b/src/components/marketing/hero.tsx
@@ -81,6 +81,13 @@ export function Hero() {
>
Se menuer
+
+ Om Christian
+
Forespørg booking
+
+ Login
+
diff --git a/src/components/ui/date-picker.tsx b/src/components/ui/date-picker.tsx
index 358b0a3..5f350e0 100644
--- a/src/components/ui/date-picker.tsx
+++ b/src/components/ui/date-picker.tsx
@@ -53,7 +53,7 @@ export function DatePicker({
)}
{isOpen && (
-
+
diff --git a/src/middleware.ts b/src/middleware.ts
index 617f266..95fe649 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,17 +1,85 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
-import { authenticate } from './lib/auth'
+
+const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this'
+
+// JWT verification using Web API crypto for Edge Runtime
+async function verifyJWT(token: string, secret: string) {
+ try {
+ const [header, payload, signature] = token.split('.')
+
+ if (!header || !payload || !signature) {
+ return null
+ }
+
+ // Decode header and payload
+ const decodedHeader = JSON.parse(atob(header))
+ const decodedPayload = JSON.parse(atob(payload))
+
+ // Check if token is expired
+ if (decodedPayload.exp && Date.now() >= decodedPayload.exp * 1000) {
+ return null
+ }
+
+ // Create signature to verify
+ const encoder = new TextEncoder()
+ const data = encoder.encode(`${header}.${payload}`)
+ const key = await crypto.subtle.importKey(
+ 'raw',
+ encoder.encode(secret),
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign']
+ )
+
+ const signatureBuffer = await crypto.subtle.sign('HMAC', key, data)
+ const expectedSignature = btoa(String.fromCharCode(...new Uint8Array(signatureBuffer)))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '')
+
+ // Compare signatures
+ const actualSignature = signature.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
+
+ if (expectedSignature === actualSignature) {
+ return decodedPayload
+ }
+
+ return null
+ } catch (error) {
+ return null
+ }
+}
export async function middleware(request: NextRequest) {
// Only protect /admin routes
if (request.nextUrl.pathname.startsWith('/admin')) {
- // Allow Next.js RSC internal requests to admin pages to pass through
- // (they use Accept: text/x-component). Protect regular HTML/API requests.
- const accept = request.headers.get('accept') || ''
- if (accept.includes('text/x-component')) {
- return NextResponse.next()
+ // Check for JWT token in cookie
+ const token = request.cookies.get('authToken')?.value
+
+ console.log('Middleware - Path:', request.nextUrl.pathname)
+ console.log('Middleware - Token present:', !!token)
+ console.log('Middleware - Token value:', token?.substring(0, 20) + '...')
+
+ if (!token) {
+ console.log('Middleware - No token, redirecting to login')
+ return NextResponse.redirect(new URL('/login', request.url))
+ }
+
+ try {
+ const decoded = await verifyJWT(token, JWT_SECRET)
+ if (decoded) {
+ console.log('Middleware - Token valid, user:', decoded.username)
+ return NextResponse.next()
+ } else {
+ console.log('Middleware - Token verification failed')
+ return NextResponse.redirect(new URL('/login', request.url))
+ }
+ } catch (error) {
+ // Invalid token, redirect to login
+ console.log('Middleware - Invalid token:', error)
+ return NextResponse.redirect(new URL('/login', request.url))
}
- return await authenticate(request)
}
return NextResponse.next()
diff --git a/test-results/.last-run.json b/test-results/.last-run.json
new file mode 100644
index 0000000..cbcc1fb
--- /dev/null
+++ b/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/tests/login-debug.spec.ts b/tests/login-debug.spec.ts
new file mode 100644
index 0000000..24f6169
--- /dev/null
+++ b/tests/login-debug.spec.ts
@@ -0,0 +1,57 @@
+import { test, expect } from '@playwright/test'
+
+test('Debug login with console output', async ({ page }) => {
+ // Listen to console logs
+ page.on('console', msg => {
+ console.log(`BROWSER LOG: ${msg.type()}: ${msg.text()}`)
+ })
+
+ // Listen to requests
+ page.on('request', request => {
+ if (request.url().includes('/api/auth/login')) {
+ console.log(`LOGIN REQUEST: ${request.method()} ${request.url()}`)
+ console.log(`REQUEST BODY: ${request.postData()}`)
+ }
+ })
+
+ // Listen to responses
+ page.on('response', response => {
+ if (response.url().includes('/api/auth/login')) {
+ console.log(`LOGIN RESPONSE: ${response.status()} ${response.statusText()}`)
+ }
+ })
+
+ // Navigate to login
+ await page.goto('http://localhost:3000/login')
+
+ // Fill form
+ await page.fill('#username', 'brotha')
+ await page.fill('#password', 'makefood1')
+
+ console.log('Form filled, clicking submit...')
+
+ // Click login button
+ await page.click('button[type="submit"]')
+
+ // Wait for navigation
+ await page.waitForTimeout(3000)
+
+ // Check final state
+ const currentUrl = page.url()
+ console.log('Final URL:', currentUrl)
+
+ // Check if there are any error messages visible
+ const errorElement = await page.locator('.bg-red-50').first().isVisible().catch(() => false)
+ if (errorElement) {
+ const errorText = await page.locator('.bg-red-50').first().textContent()
+ console.log('Visible error:', errorText)
+ }
+
+ // Check cookies
+ const cookies = await page.context().cookies()
+ const authCookie = cookies.find(c => c.name === 'authToken')
+ console.log('Auth cookie present:', !!authCookie)
+ if (authCookie) {
+ console.log('Auth cookie value length:', authCookie.value.length)
+ }
+})
\ No newline at end of file
diff --git a/tests/login-manual.spec.ts b/tests/login-manual.spec.ts
new file mode 100644
index 0000000..08243ca
--- /dev/null
+++ b/tests/login-manual.spec.ts
@@ -0,0 +1,42 @@
+import { test, expect } from '@playwright/test'
+
+test('Manual login test with form submission', async ({ page }) => {
+ // Navigate to login
+ await page.goto('http://localhost:3000/login')
+
+ // Fill form
+ await page.fill('#username', 'brotha')
+ await page.fill('#password', 'makefood1')
+
+ // Click login button
+ await page.click('button[type="submit"]')
+
+ // Wait for navigation or error
+ await page.waitForTimeout(2000)
+
+ // Check what happened
+ const currentUrl = page.url()
+ console.log('Final URL:', currentUrl)
+
+ if (currentUrl.includes('/admin')) {
+ console.log('✅ Successfully redirected to admin')
+ expect(currentUrl).toContain('/admin')
+ } else if (currentUrl.includes('/login')) {
+ console.log('❌ Still on login page - login failed')
+
+ // Check for error messages
+ const errorMessage = await page.locator('.bg-red-50').textContent().catch(() => null)
+ if (errorMessage) {
+ console.log('Error message:', errorMessage)
+ }
+
+ // Check console logs
+ const logs = await page.evaluate(() => {
+ // @ts-ignore
+ return window.loginLogs || []
+ })
+ console.log('Console logs:', logs)
+
+ expect(currentUrl).toContain('/admin')
+ }
+})
\ No newline at end of file
diff --git a/visual-calendar-test.spec.ts b/visual-calendar-test.spec.ts
new file mode 100644
index 0000000..bdb7aa1
--- /dev/null
+++ b/visual-calendar-test.spec.ts
@@ -0,0 +1,42 @@
+import { test } from '@playwright/test';
+
+test('Visual test - take screenshot and compare with expected layout', async ({ page }) => {
+ // Set basic auth header
+ await page.setExtraHTTPHeaders({
+ 'Authorization': 'Basic ' + Buffer.from('brotha:makefood1').toString('base64')
+ });
+
+ await page.goto('http://localhost:3000/admin/availability');
+ await page.waitForSelector('.cook-calendar');
+ await page.waitForTimeout(2000);
+
+ // Get the actual table structure
+ const tableRows = await page.locator('.cook-calendar tbody tr').all();
+
+ console.log('=== ACTUAL CALENDAR LAYOUT ===');
+
+ for (let i = 0; i < tableRows.length; i++) {
+ const cells = await tableRows[i].locator('td button').allTextContents();
+ console.log(`Row ${i + 1}: ${cells.join(' ')}`);
+ }
+
+ // Get headers
+ const headers = await page.locator('.cook-calendar thead th').allTextContents();
+ console.log('Headers:', headers.join(' '));
+
+ // What we expect for November 2025:
+ // November 1, 2025 is Saturday, so in week starting Monday:
+ // ma ti on to fr lö sö
+ // Should be: 27 28 29 30 31 1 2
+
+ const firstRow = await tableRows[0].locator('td button').allTextContents();
+ console.log('\nFirst row actual:', firstRow);
+ console.log('First row expected: [27, 28, 29, 30, 31, 1, 2] (1 should be in 6th position - Saturday)');
+
+ if (firstRow[5] === '1') {
+ console.log('✅ November 1st is correctly in position 6 (Saturday)');
+ } else {
+ console.log(`❌ November 1st is in wrong position. Found "${firstRow[5]}" in position 6`);
+ console.log('Position of "1":', firstRow.indexOf('1'));
+ }
+});
\ No newline at end of file