First commit

This commit is contained in:
ismail 2026-02-02 16:09:01 +01:00
commit c789ebf291
139 changed files with 23556 additions and 0 deletions

11
.dockeringore Normal file
View File

@ -0,0 +1,11 @@
node_modules
npm-debug.log
.next
.git
.gitignore
README.md
*.md
.env
.env.local
docker-compose.override.yml
mysql-data/

37
.env Normal file
View File

@ -0,0 +1,37 @@
# Docker Database Configuration (used by docker-compose)
ROOT_DB_PASSWORD=RootSecurePassword789!
APP_DB_PASSWORD=AppSecurePassword456!
PMA_CONTROL_PASS=ControlPassword123!
PMA_SECRET=YourBlowfishSecretKeyHere32CharsMinimum!
# Application Database Configuration (defaults - overridden in .env.local)
DB_HOST=localhost
DB_PORT=3306
DB_NAME=nordic_storium
DB_USER=nordic_app_user
DB_PASSWORD=AppSecurePassword456!
DATABASE_URL="mysql://nordic_app_user:AppSecurePassword456!@localhost:3306/nordic_storium"
# JWT Authentication
JWT_SECRET=YourSuperSecretJWTKeyForNordicStorium2024!
# Session Security
SESSION_SECRET=YourSessionSecretKey123!
ENCRYPTION_KEY=Your32ByteEncryptionKeyHere123456789012!
# Next.js Configuration
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=/api
NODE_ENV=development
# Enable/Disable features
ENABLE_REGISTRATION=true
ENABLE_EMAIL_VERIFICATION=false
ENABLE_RATE_LIMITING=true
MAX_LOGIN_ATTEMPTS=5
CONTACT_EMAIL=ismail@abrahem.se
LOG_LEVEL=debug
ENABLE_SQL_LOGGING=false

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Environment files
.env.local
.env.development.local
.env.test.local
.env.production.local
# Docker
docker-compose.override.yml
# Database data
mysql-data/
nordicstorium-mysql-data/
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.next/
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db

18
Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Expose port
EXPOSE 3000
# Start the application
CMD ["npm", "run", "dev"]

43
SMTP_SETUP_GUIDE.md Normal file
View File

@ -0,0 +1,43 @@
# 📧 Guide: Så här ställer du in SMTP för Nordic Storium
För att applikationen ska kunna skicka e-post (verifiering, lösenordsåterställning, nyhetsbrev) behöver du lägga till SMTP-inställningar i din `.env.local`.
## Alternativ 1: Gmail (Enklast för test)
1. Gå till ditt [Google-konto](https://myaccount.google.com/).
2. Aktivera **Tvåstegsverifiering**.
3. Sök efter **App-lösenord**.
4. Skapa ett nytt lösenord (välj namnet "Nordic Storium").
5. Kopiera det 16-siffriga lösenordet och lägg till i din `.env.local`:
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=din-epost@gmail.com
SMTP_PASS=ditt-app-lösenord-utan-mellanslag
SMTP_FROM=din-epost@gmail.com
NEXT_PUBLIC_APP_URL=http://localhost:3000
```
## Alternativ 2: Outlook / Office 365
```env
SMTP_HOST=smtp.office365.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=din-epost@hotmail.com
SMTP_PASS=ditt-lösenord
SMTP_FROM=din-epost@hotmail.com
NEXT_PUBLIC_APP_URL=http://localhost:3000
```
## Hur du provar
1. Spara filen `.env.local`.
2. Starta om din Docker-container: `docker compose restart nordicstorium-app`.
3. Gå till `/register` och skapa ett konto.
4. Kontrollera din inkorg!
> [!TIP]
> Om du inte vill ställa in SMTP direkt kan du se verifieringslänken i loggarna:
> `docker compose logs nordicstorium-app -f`

61
build_log.txt Normal file
View File

@ -0,0 +1,61 @@
> nordic-storium@0.1.0 build
> next build --turbopack
▲ Next.js 15.5.0 (Turbopack)
- Environments: .env.local, .env
Creating an optimized production build ...
✓ Finished writing to disk in 14ms
✓ Compiled successfully in 3.9s
Linting and checking validity of types ...
Failed to compile.
./src/app/api/auth/forgot-password/route.ts
41:15 Warning: 'resetUrl' is assigned a value but never used. @typescript-eslint/no-unused-vars
./src/app/api/auth/google/callback/route.ts
36:13 Warning: Unused eslint-disable directive (no problems were reported from '@typescript-eslint/no-explicit-any').
41:18 Error: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/app/api/auth/login/route.ts
4:24 Warning: 'AuthResponse' is defined but never used. @typescript-eslint/no-unused-vars
./src/app/api/auth/me/route.ts
56:75 Warning: 'country' is assigned a value but never used. @typescript-eslint/no-unused-vars
./src/app/api/auth/register/route.ts
3:24 Warning: 'generateToken' is defined but never used. @typescript-eslint/no-unused-vars
4:10 Warning: 'RegisterRequest' is defined but never used. @typescript-eslint/no-unused-vars
4:27 Warning: 'AuthResponse' is defined but never used. @typescript-eslint/no-unused-vars
./src/app/login/page.tsx
157:18 Warning: 'err' is defined but never used. @typescript-eslint/no-unused-vars
./src/app/me/page.tsx
8:28 Warning: 'UserIcon' is defined but never used. @typescript-eslint/no-unused-vars
692:49 Warning: Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
./src/app/register/page.tsx
173:18 Warning: 'err' is defined but never used. @typescript-eslint/no-unused-vars
./src/app/teori/page.tsx
182:17 Warning: Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
./src/components/ImageWithFallback.tsx
23:9 Warning: Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
27:5 Warning: Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
./src/components/Navbar.tsx
3:23 Warning: 'useRouter' is defined but never used. @typescript-eslint/no-unused-vars
15:28 Warning: 'loading' is assigned a value but never used. @typescript-eslint/no-unused-vars
./src/context/AuthContext.tsx
5:21 Warning: 'usePathname' is defined but never used. @typescript-eslint/no-unused-vars
./src/lib/validation.ts
165:18 Warning: 'e' is defined but never used. @typescript-eslint/no-unused-vars
206:14 Warning: 'error' is defined but never used. @typescript-eslint/no-unused-vars
info - Need to disable some ESLint rules? Learn more here: https://nextjs.org/docs/app/api-reference/config/eslint#disabling-rules

View File

@ -0,0 +1,75 @@
<?php
/* phpMyAdmin configuration for Nordic Storium */
// Disable auto-login
$cfg['Servers'][1]['auth_type'] = 'cookie';
// Enable security features
$cfg['LoginCookieRecall'] = false;
$cfg['LoginCookieStore'] = 0;
$cfg['AllowArbitraryServer'] = false;
$cfg['Servers'][1]['AllowNoPassword'] = false;
$cfg['Servers'][1]['AllowRoot'] = true;
$cfg['Servers'][1]['AllowDeny']['order'] = 'deny,allow';
$cfg['Servers'][1]['AllowDeny']['rules'] = array();
// Enable logging
$cfg['Servers'][1]['LogoutURL'] = './index.php';
$cfg['Servers'][1]['SessionTimeZone'] = 'UTC';
// Disable dangerous features
$cfg['Servers'][1]['AllowUserDropDatabase'] = false;
$cfg['Servers'][1]['DisableIS'] = false;
// Set control user for advanced features
$cfg['Servers'][1]['controluser'] = 'pma_control';
$cfg['Servers'][1]['controlpass'] = getenv('PMA_CONTROL_PASS');
// Bookmark feature
$cfg['Servers'][1]['bookmarktable'] = 'pma__bookmark';
// For PDF generation
$cfg['Servers'][1]['pdf_pages'] = 'pma__pdf_pages';
// History feature
$cfg['Servers'][1]['history'] = 'pma__history';
// Designer feature
$cfg['Servers'][1]['designer_settings'] = 'pma__designer_settings';
// Export templates
$cfg['Servers'][1]['export_templates'] = 'pma__export_templates';
// Recent tables
$cfg['Servers'][1]['recent'] = 'pma__recent';
// Favorite tables
$cfg['Servers'][1]['favorite'] = 'pma__favorite';
// Table UI preferences
$cfg['Servers'][1]['table_uiprefs'] = 'pma__table_uiprefs';
// User preferences
$cfg['Servers'][1]['userconfig'] = 'pma__userconfig';
// Track changes
$cfg['Servers'][1]['tracking'] = 'pma__tracking';
// User preferences storage
$cfg['Servers'][1]['users'] = 'pma__users';
// Navigation width
$cfg['Servers'][1]['navigationwidth'] = 240;
// Max rows
$cfg['MaxRows'] = 50;
// Theme
$cfg['ThemeDefault'] = 'pmahomme';
// Default language
$cfg['DefaultLang'] = 'en';
// Enable SSL if available
$cfg['ForceSSL'] = false;
?>

73
docker-compose.yml Normal file
View File

@ -0,0 +1,73 @@
services:
nordicstorium-app:
build: .
container_name: nordicstorium-app
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: development
DB_HOST: nordicstorium-db
DB_PORT: 3306
DB_NAME: nordic_storium
DB_USER: nordic_app_user
DB_PASSWORD: ${APP_DB_PASSWORD}
DATABASE_URL: "mysql://nordic_app_user:${APP_DB_PASSWORD}@nordicstorium-db:3306/nordic_storium"
JWT_SECRET: ${JWT_SECRET}
volumes:
- ./:/app
- /app/node_modules
- /app/.next
depends_on:
nordicstorium-db:
condition: service_healthy
nordicstorium-db:
image: mariadb:10.11.4
container_name: nordicstorium-db
restart: unless-stopped
ports:
- "3306:3306"
environment:
MARIADB_ROOT_PASSWORD: ${ROOT_DB_PASSWORD}
MARIADB_DATABASE: nordic_storium
MARIADB_USER: nordic_app_user
MARIADB_PASSWORD: ${APP_DB_PASSWORD}
MARIADB_ROOT_HOST: "%"
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --skip-name-resolve
- --bind-address=0.0.0.0
volumes:
- nordicstorium-mysql-data:/var/lib/mysql
- ./schemas:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect"]
start_period: 30s
interval: 10s
timeout: 5s
retries: 5
nordicstorium-phpmyadmin:
image: phpmyadmin/phpmyadmin:latest
container_name: nordicstorium-phpmyadmin
restart: unless-stopped
ports:
- "8081:80"
environment:
PMA_HOST: nordicstorium-db
PMA_PORT: 3306
PMA_ARBITRARY: 0
UPLOAD_LIMIT: 50M
# Remove auto-login - user must login manually
# PMA_USER and PMA_PASSWORD are NOT set - this forces login
volumes:
- phpmyadmin-sessions:/sessions
depends_on:
nordicstorium-db:
condition: service_healthy
volumes:
nordicstorium-mysql-data:
phpmyadmin-sessions:

25
eslint.config.mjs Normal file
View File

@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;

View File

@ -0,0 +1,32 @@
-- Migration: Add Messaging System
-- Creates tables for customer-admin conversations and messages
-- Conversations table
CREATE TABLE IF NOT EXISTS conversations (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
product_id INT NULL,
subject VARCHAR(255) NOT NULL,
status ENUM('open', 'closed') DEFAULT 'open',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL,
INDEX idx_user_status (user_id, status),
INDEX idx_updated (updated_at DESC)
);
-- Messages table
CREATE TABLE IF NOT EXISTS messages (
id INT AUTO_INCREMENT PRIMARY KEY,
conversation_id INT NOT NULL,
sender_id INT NOT NULL,
sender_role ENUM('customer', 'admin') NOT NULL,
content TEXT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_conversation (conversation_id, created_at),
INDEX idx_unread (conversation_id, is_read)
);

6
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

16
next.config.ts Normal file
View File

@ -0,0 +1,16 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
port: '',
pathname: '/**',
},
],
},
};
export default nextConfig;

8403
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

69
package.json Normal file
View File

@ -0,0 +1,69 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "nordic-storium",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev:docker": "docker compose up nordicstorium-app",
"dev:full": "docker compose up",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint .",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:stop": "docker compose stop",
"docker:logs": "docker compose logs -f",
"docker:logs-app": "docker compose logs -f nordicstorium-app",
"docker:logs-db": "docker compose logs -f nordicstorium-db",
"docker:build": "docker compose build",
"docker:restart": "docker compose restart",
"docker:reset": "docker compose down -v && docker compose up -d",
"db:up": "docker compose up -d nordicstorium-db nordicstorium-phpmyadmin",
"db:down": "docker compose stop nordicstorium-db nordicstorium-phpmyadmin",
"db:shell": "docker exec -it nordicstorium-db mysql -u storium_user -p nordic_storium",
"db:shell-root": "docker exec -it nordicstorium-db mysql -u root -p",
"db:logs": "docker compose logs -f nordicstorium-db",
"db:reset": "docker compose down -v nordicstorium-db && docker compose up -d nordicstorium-db",
"setup": "npm install && docker compose build",
"clean": "docker compose down -v && rm -rf mysql-data"
},
"dependencies": {
"@emailjs/browser": "^4.4.1",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"bcrypt": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"google-auth-library": "^10.5.0",
"jsonwebtoken": "^9.0.3",
"libphonenumber-js": "^1.12.35",
"lucide-react": "^0.544.0",
"mysql2": "^3.16.1",
"next": "15.5.0",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.12",
"otplib": "^13.2.0",
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-icons": "^5.5.0",
"tailwind-merge": "^3.3.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.19.30",
"@types/nodemailer": "^7.0.5",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"eslint": "^9",
"eslint-config-next": "15.5.0",
"tailwindcss": "^4",
"typescript": "^5.9.3"
}
}

5
postcss.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
public/artifacts/omoss1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
public/artifacts/omoss2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://www.nordicstorium.se/sitemap.xml

19
public/site.webmanifest Normal file
View File

@ -0,0 +1,19 @@
{
"name": "Nordic Storium",
"short_name": "Nordic Storium",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

60
public/sitemap.xml Normal file
View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.nordicstorium.se/</loc>
<lastmod>2026-02-02</lastmod>
<priority>1.00</priority>
</url>
<url>
<loc>https://www.nordicstorium.se/products</loc>
<lastmod>2026-02-02</lastmod>
<priority>0.90</priority>
</url>
<url>
<loc>https://www.nordicstorium.se/om-oss</loc>
<lastmod>2026-02-02</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://www.nordicstorium.se/kontakta-oss</loc>
<lastmod>2026-02-02</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://www.nordicstorium.se/salja-mobler</loc>
<lastmod>2026-02-02</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://www.nordicstorium.se/login</loc>
<lastmod>2026-02-02</lastmod>
<priority>0.60</priority>
</url>
<url>
<loc>https://www.nordicstorium.se/register</loc>
<lastmod>2026-02-02</lastmod>
<priority>0.60</priority>
</url>
<url>
<loc>https://www.nordicstorium.se/villkor</loc>
<lastmod>2026-02-02</lastmod>
<priority>0.50</priority>
</url>
<url>
<loc>https://www.nordicstorium.se/integritetspolicy</loc>
<lastmod>2026-02-02</lastmod>
<priority>0.50</priority>
</url>
</urlset>

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

96
schemas/init.sql Normal file
View File

@ -0,0 +1,96 @@
-- Nordic Storium Database Initialization
-- Simple and error-free setup
-- 1. Create the database if it doesn't exist
CREATE DATABASE IF NOT EXISTS nordic_storium
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
-- 2. Use the database
USE nordic_storium;
-- 3. Create users table
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(100),
role ENUM('user', 'admin') DEFAULT 'user',
email_verified BOOLEAN DEFAULT FALSE,
verification_token VARCHAR(255),
newsletter_subscribed BOOLEAN DEFAULT FALSE,
two_factor_enabled BOOLEAN DEFAULT FALSE,
two_factor_secret VARCHAR(255),
personnummer VARCHAR(15),
mobile VARCHAR(20),
address VARCHAR(255),
zip_code VARCHAR(10),
city VARCHAR(100),
country VARCHAR(100) DEFAULT 'Sverige',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4. Insert root admin user (password: 3DBFC7FEF43B45E887C8E54205C8EC8F)
INSERT IGNORE INTO users (email, username, password_hash, full_name, role, email_verified) VALUES
('root@nordicstorium.com', 'root', '$2b$10$MDURVVPDTo60o.W5rHJOPex3jwR8.s.xc5e1dpYF8DG7bu5SXXwLq', 'Root Administrator', 'admin', TRUE);
-- 5. Create categories table (Required for webshop)
CREATE TABLE IF NOT EXISTS categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6. Create products table (Full version)
CREATE TABLE IF NOT EXISTS products (
id INT AUTO_INCREMENT PRIMARY KEY,
category_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(10, 2) NOT NULL,
stock INT DEFAULT 0,
image_url VARCHAR(500),
show_on_homepage BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT,
INDEX idx_category (category_id),
INDEX idx_name (name),
INDEX idx_price (price)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 7. Insert sample data
INSERT IGNORE INTO categories (id, name, description) VALUES
(1, 'Furniture', 'Chairs, tables, and other furniture'),
(2, 'Decor', 'Home decoration items');
INSERT IGNORE INTO products (category_id, name, description, price, stock) VALUES
(1, 'Nordic Chair', 'Handcrafted wooden chair', 299.99, 10),
(2, 'Viking Shield', 'Decorative shield with Norse patterns', 199.99, 5),
(2, 'Rune Stone', 'Engraved stone with ancient symbols', 89.99, 20);
-- 8. Create user_sessions table for session management
CREATE TABLE IF NOT EXISTS user_sessions (
session_id VARCHAR(128) PRIMARY KEY,
user_id INT NOT NULL,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id),
INDEX idx_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 9. Show what was created
SELECT 'Nordic Storium database initialized successfully!' as message;
SELECT COUNT(*) as user_count FROM users;
SELECT COUNT(*) as product_count FROM products;
SELECT TABLE_NAME, TABLE_ROWS
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'nordic_storium';

View File

@ -0,0 +1,50 @@
const mysql = require('mysql2/promise');
const fs = require('fs');
const path = require('path');
// Load env vars manually
const envPath = path.resolve(__dirname, '../.env');
const envContent = fs.readFileSync(envPath, 'utf8');
const envVars = {};
envContent.split('\n').forEach(line => {
const [key, value] = line.split('=');
if (key && value) {
envVars[key.trim()] = value.trim().replace(/"/g, '');
}
});
const dbConfig = {
host: envVars.DB_HOST || 'localhost',
user: envVars.DB_USER || 'nordic_app_user',
password: envVars.DB_PASSWORD,
database: envVars.DB_NAME || 'nordic_storium',
port: parseInt(envVars.DB_PORT || '3306')
};
async function main() {
let connection;
try {
console.log('Connecting to database...');
connection = await mysql.createConnection(dbConfig);
console.log('Connected!');
// Add image_url column to categories table if it doesn't exist
console.log('Adding image_url column to categories table...');
try {
await connection.query('ALTER TABLE categories ADD COLUMN image_url VARCHAR(500)');
console.log('Added image_url column to categories');
} catch (e) {
if (e.code !== 'ER_DUP_FIELDNAME') throw e;
console.log('image_url column already exists in categories');
}
console.log('✅ Migration completed successfully!');
} catch (error) {
console.error('❌ Error:', error);
} finally {
if (connection) await connection.end();
}
}
main();

184
scripts/migrate_and_seed.js Normal file
View File

@ -0,0 +1,184 @@
const mysql = require('mysql2/promise');
const fs = require('fs');
const path = require('path');
// Load env vars manually since we don't have dotenv
const envPath = path.resolve(__dirname, '../.env');
const envContent = fs.readFileSync(envPath, 'utf8');
const envVars = {};
envContent.split('\n').forEach(line => {
const [key, value] = line.split('=');
if (key && value) {
envVars[key.trim()] = value.trim().replace(/"/g, ''); // Remove quotes
}
});
const dbConfig = {
host: envVars.DB_HOST || 'localhost',
user: envVars.DB_USER || 'nordic_app_user',
password: envVars.DB_PASSWORD,
database: envVars.DB_NAME || 'nordic_storium',
port: parseInt(envVars.DB_PORT || '3306')
};
const categories = [
{ name: 'Kontorsstolar', image: '/assets/Images/categories/office-chair.jpg', description: 'Ergonomiska stolar för arbete' },
{ name: 'Skrivbord', image: '/assets/Images/categories/desk.jpg', description: 'Höj- och sänkbara skrivbord' },
{ name: 'Konferensmöbler', image: '/assets/Images/categories/conference.jpg', description: 'För effektiva möten' },
{ name: 'Förvaring', image: '/assets/Images/categories/storage.jpg', description: 'Skåp och hyllor' },
{ name: 'Lounge & Soffor', image: '/assets/Images/categories/lounge.jpg', description: 'Bekväm inredning för pausutrymmen' }
];
const products = [
{
name: 'Ergonomisk Kontorsstol Herman Miller',
price: 4500,
originalPrice: 12000,
image: '/assets/Images/products/herman-miller.jpg',
category: 'Kontorsstolar',
isPopular: true,
isNew: false,
created_at: '2025-11-15 10:00:00'
},
{
name: 'Höj- och sänkbart Skrivbord 160x80',
price: 2900,
originalPrice: 6500,
image: '/assets/Images/products/desk-white.jpg',
category: 'Skrivbord',
isPopular: true,
isNew: false,
created_at: '2025-12-01 10:00:00'
},
{
name: 'Konferensstol Läder Svart',
price: 1200,
originalPrice: 3500,
image: '/assets/Images/products/conf-chair.jpg',
category: 'Konferensmöbler',
isPopular: false,
isNew: true,
created_at: '2026-01-20 10:00:00'
},
{
name: 'Designsoffa 3-sits Grå',
price: 5500,
originalPrice: 15000,
image: '/assets/Images/products/sofa-grey.jpg',
category: 'Lounge & Soffor',
isPopular: true,
isNew: true,
created_at: '2026-01-25 10:00:00'
},
{
name: 'Arkivskåp Plåt Vit',
price: 1500,
originalPrice: 3000,
image: '/assets/Images/products/cabinet.jpg',
category: 'Förvaring',
isPopular: false,
isNew: true,
created_at: '2026-01-28 10:00:00'
},
{
name: 'Kinnarps 6000 Plus',
price: 1900,
originalPrice: 6000,
image: '/assets/Images/products/kinnarps.jpg',
category: 'Kontorsstolar',
isPopular: true,
isNew: false,
created_at: '2025-10-10 10:00:00'
}
];
async function main() {
let connection;
try {
console.log('Connecting to database...');
connection = await mysql.createConnection(dbConfig);
console.log('Connected!');
// 1. Alter Categories Table
console.log('Updating categories table schema...');
try {
await connection.query('ALTER TABLE categories ADD COLUMN image_url VARCHAR(255)');
console.log('Added image_url column to categories');
} catch (e) {
if (e.code !== 'ER_DUP_FIELDNAME') throw e;
console.log('image_url column already exists in categories');
}
// 2. Alter Products Table
console.log('Updating products table schema...');
const productColumns = [
'ALTER TABLE products ADD COLUMN is_popular BOOLEAN DEFAULT FALSE',
'ALTER TABLE products ADD COLUMN is_new BOOLEAN DEFAULT FALSE',
'ALTER TABLE products ADD COLUMN is_trendy BOOLEAN DEFAULT FALSE',
'ALTER TABLE products ADD COLUMN original_price DECIMAL(10, 2)'
];
for (const sql of productColumns) {
try {
await connection.query(sql);
console.log(`Executed: ${sql}`);
} catch (e) {
if (e.code !== 'ER_DUP_FIELDNAME') throw e;
console.log(`Column already exists: ${sql}`);
}
}
// 3. Clear Data
console.log('Clearing existing data...');
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
await connection.query('TRUNCATE TABLE products');
await connection.query('TRUNCATE TABLE categories');
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
// 4. Insert Categories
console.log('Inserting categories...');
for (const cat of categories) {
await connection.execute(
'INSERT INTO categories (name, image_url, description) VALUES (?, ?, ?)',
[cat.name, cat.image, cat.description]
);
}
// 5. Insert Products
console.log('Inserting products...');
const [catRows] = await connection.query('SELECT id, name FROM categories');
for (const prod of products) {
const cat = catRows.find(c => c.name === prod.category);
if (!cat) {
console.warn(`Category not found for product: ${prod.name}`);
continue;
}
await connection.execute(
`INSERT INTO products
(category_id, name, price, original_price, image_url, is_popular, is_new, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
cat.id,
prod.name,
prod.price,
prod.originalPrice,
prod.image,
prod.isPopular,
prod.isNew,
prod.created_at
]
);
}
console.log('✅ Migration and Seeding Completed Successfully!');
} catch (error) {
console.error('❌ Error:', error);
} finally {
if (connection) await connection.end();
}
}
main();

121
setup.sh Executable file
View File

@ -0,0 +1,121 @@
#!/bin/bash
echo "🚀 Setting up Nordic Storium Development Environment"
echo "=================================================="
# Check for required files
if [ ! -f .env ]; then
echo "Creating .env file from .env.example..."
cp .env.example .env
echo "⚠️ Please edit .env file with your actual values!"
echo " Important: Change all passwords and secret keys!"
exit 1
fi
if [ ! -f .env.local ]; then
echo "Creating .env.local file..."
cp .env .env.local
echo "✅ Created .env.local (you can override settings here)"
fi
# Check Docker
if ! command -v docker &> /dev/null; then
echo "❌ Docker is not installed. Please install Docker first."
exit 1
fi
if ! command -v docker compose &> /dev/null; then
echo "❌ Docker Compose is not installed. Please install Docker Compose first."
exit 1
fi
# Load environment variables
echo "Loading environment variables..."
set -a
source .env
set +a
# Create required directories
echo "Creating required directories..."
mkdir -p schemas config
# Create phpMyAdmin config if it doesn't exist
if [ ! -f config/user.config.inc.php ]; then
echo "Creating phpMyAdmin configuration..."
cat > config/user.config.inc.php << 'EOF'
<?php
/* phpMyAdmin configuration for Nordic Storium */
$cfg['Servers'][1]['auth_type'] = 'cookie';
$cfg['LoginCookieRecall'] = false;
$cfg['LoginCookieStore'] = 0;
$cfg['AllowArbitraryServer'] = false;
$cfg['Servers'][1]['AllowNoPassword'] = false;
$cfg['Servers'][1]['AllowRoot'] = true;
$cfg['ForceSSL'] = false;
EOF
fi
# Create database schema if it doesn't exist
if [ ! -f schemas/init.sql ]; then
echo "Creating database schema..."
cat > schemas/init.sql << 'EOF'
-- Nordic Storium Database Initialization
USE nordic_storium;
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(100),
role ENUM('user', 'admin', 'moderator') DEFAULT 'user',
is_active BOOLEAN DEFAULT TRUE,
email_verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
EOF
fi
# Build and start containers
echo "Building Docker images..."
docker compose build --no-cache
echo "Starting services..."
docker compose up -d
echo "Waiting for database to be ready..."
sleep 15
# Test database connection
echo "Testing database connection..."
for i in {1..10}; do
if docker exec nordicstorium-db mysqladmin ping -h localhost -u root -p${ROOT_DB_PASSWORD} --silent; then
echo "✅ Database is ready!"
break
fi
echo "⏳ Waiting for database... ($i/10)"
sleep 5
done
echo ""
echo "=================================================="
echo "✅ Setup Complete!"
echo ""
echo "📱 Your application is running at:"
echo " 🌐 Website: http://localhost:3000"
echo " 🗄️ Database Admin: http://localhost:8081"
echo ""
echo "🔐 Login credentials:"
echo " phpMyAdmin (root): root / ${ROOT_DB_PASSWORD}"
echo " phpMyAdmin (app): nordic_app_user / ${APP_DB_PASSWORD}"
echo ""
echo "📊 Test database connection:"
echo " curl http://localhost:3000/api/db-test"
echo ""
echo "🛑 To stop everything: docker compose down"
echo "📝 To view logs: docker compose logs -f"
echo "=================================================="

View File

@ -0,0 +1,386 @@
"use client";
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useAuth } from '@/context/AuthContext';
import { useRouter } from 'next/navigation';
import { Plus, Edit, Trash2, X, Save, Upload } from 'lucide-react';
import '@/styles/Auth.css';
import '@/styles/Admin.css';
interface Category {
id: number;
name: string;
description: string;
image_url?: string;
show_on_homepage: boolean;
created_at: string;
}
export default function AdminCategoriesPage() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [showModal, setShowModal] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
// Form State
const [formData, setFormData] = useState({
name: '',
description: '',
image_url: '',
show_on_homepage: false
});
useEffect(() => {
if (!authLoading && user?.role !== 'admin') {
router.push('/login');
return;
}
if (user?.role === 'admin') {
fetchCategories();
}
}, [user, authLoading, router]);
const fetchCategories = async () => {
try {
const res = await fetch('/api/categories');
if (res.ok) {
const data = await res.json();
setCategories(data);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const resetForm = () => {
setFormData({
name: '',
description: '',
image_url: '',
show_on_homepage: false
});
setIsEditing(false);
setEditId(null);
};
const handleOpenAdd = () => {
resetForm();
setShowModal(true);
};
const handleEdit = (category: Category) => {
setFormData({
name: category.name,
description: category.description || '',
image_url: category.image_url || '',
show_on_homepage: category.show_on_homepage || false
});
setIsEditing(true);
setEditId(category.id);
setShowModal(true);
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) return;
setUploading(true);
const uploadData = new FormData();
uploadData.append('file', e.target.files[0]);
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
const res = await fetch('/api/admin/upload-category', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: uploadData
});
if (res.ok) {
const data = await res.json();
setFormData(prev => ({
...prev,
image_url: data.url
}));
} else {
alert('Uppladdning misslyckades');
}
} catch (_) {
alert('Uppladdning misslyckades');
} finally {
setUploading(false);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
const url = isEditing ? `/api/admin/categories/${editId}` : '/api/admin/categories';
const method = isEditing ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to save');
}
await fetchCategories();
setShowModal(false);
resetForm();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Kunde inte spara kategorin';
alert(message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Är du säker på att du vill ta bort den här kategorin?')) return;
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
const res = await fetch(`/api/admin/categories/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
setCategories(prev => prev.filter(c => c.id !== id));
} else {
const data = await res.json();
alert(data.error || 'Kunde inte radera kategorin');
}
} catch (err) {
console.error('Delete error', err);
alert('Ett fel uppstod vid radering');
}
};
const filteredCategories = categories.filter(c =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(c.description && c.description.toLowerCase().includes(searchTerm.toLowerCase()))
);
if (loading) return <div className="p-8 text-center text-[var(--text-color)]">Laddar kategorier...</div>;
return (
<div className="admin-container">
<div className="admin-header">
<div className="admin-title-group">
<h1>Kategorier</h1>
<p>Hantera {categories.length} produktkategorier</p>
</div>
<div className="admin-actions">
<Link href="/admin" className="auth-button secondary">
Tillbaka
</Link>
<button onClick={handleOpenAdd} className="auth-button">
<Plus size={18} className="mr-2" />
Ny Kategori
</button>
</div>
</div>
<div className="admin-table-container">
<div className="admin-controls">
<div className="relative flex-grow max-w-md">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
</div>
<input
type="text"
className="search-input"
placeholder="Sök kategori..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<table className="admin-table">
<thead>
<tr>
<th>Bild</th>
<th>Namn</th>
<th>Beskrivning</th>
<th>Startsida</th>
<th>Skapad</th>
<th className="text-right">Åtgärder</th>
</tr>
</thead>
<tbody>
{filteredCategories.map(category => (
<tr key={category.id}>
<td>
{category.image_url ? (
<img src={category.image_url} alt={category.name} className="w-12 h-12 object-cover rounded" />
) : (
<div className="w-12 h-12 bg-gray-200 rounded flex items-center justify-center text-gray-400 text-xs">-</div>
)}
</td>
<td className="font-bold">{category.name}</td>
<td className="text-sm text-gray-500 max-w-[300px] truncate">
{category.description || '-'}
</td>
<td>
{category.show_on_homepage ? (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
Visas
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
Dold
</span>
)}
</td>
<td className="text-sm text-gray-400">
{new Date(category.created_at).toLocaleDateString('sv-SE')}
</td>
<td className="text-right">
<div className="flex justify-end gap-2">
<button onClick={() => handleEdit(category)} className="p-2 hover:bg-gray-100 rounded text-blue-600">
<Edit size={16} />
</button>
<button
onClick={() => handleDelete(category.id)}
className="p-2 hover:bg-gray-100 rounded text-red-600"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredCategories.length === 0 && (
<div className="p-12 text-center text-gray-500">
Inga kategorier hittades.
</div>
)}
</div>
{/* CATEGORY MODAL */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content product-modal bg-[var(--card-bg)] rounded-xl shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b flex justify-between items-center bg-[var(--card-bg)] rounded-t-xl">
<h2 className="text-xl font-bold">{isEditing ? 'Redigera Kategori' : 'Ny Kategori'}</h2>
<button onClick={() => setShowModal(false)} className="p-2 hover:bg-gray-100 rounded-full">
<X size={20} />
</button>
</div>
<div className="modal-body scrollable p-6">
<form onSubmit={handleSave} className="space-y-6">
<div className="form-group">
<label className="form-label">Kategorinamn *</label>
<input
required
className="form-input"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Beskrivning</label>
<textarea
className="form-input min-h-[120px]"
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Kategoribild</label>
<div className="flex items-center gap-4">
<label className="auth-button secondary cursor-pointer flex items-center gap-2">
<Upload size={18} />
{uploading ? 'Laddar upp...' : 'Välj bild'}
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleUpload}
disabled={uploading}
/>
</label>
{formData.image_url && (
<div className="relative">
<img src={formData.image_url} alt="Preview" className="w-24 h-24 object-cover rounded border" />
<button
type="button"
onClick={() => setFormData({ ...formData, image_url: '' })}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1"
>
<X size={12} />
</button>
</div>
)}
</div>
<p className="text-xs text-gray-500 mt-1">Ladda upp en bild för kategorin (visas startsidan)</p>
</div>
<div className="form-group">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.show_on_homepage}
onChange={e => setFormData({ ...formData, show_on_homepage: e.target.checked })}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="form-label mb-0">Visa startsidan</span>
</label>
<p className="text-xs text-gray-500 mt-1">Max 8 kategorier kan visas startsidan</p>
</div>
<div className="sticky bottom-0 bg-[var(--card-bg)]/80 backdrop-blur-md pt-6 pb-2 mt-8 flex justify-end gap-3 z-10 rounded-b-xl border-t border-[var(--border-color)]">
<button
type="button"
onClick={() => setShowModal(false)}
className="auth-button secondary w-auto px-6"
>
Avbryt
</button>
<button
type="submit"
disabled={saving}
className="auth-button success w-auto px-6"
>
<Save size={18} className="mr-2" />
{saving ? 'Sparar...' : isEditing ? 'Uppdatera' : 'Spara Kategori'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
}

55
src/app/admin/page.tsx Normal file
View File

@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/context/AuthContext';
import '@/styles/Auth.css';
export default function AdminPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [isAuthorized, setIsAuthorized] = useState(false);
useEffect(() => {
if (!loading) {
if (!user || user.role !== 'admin') {
router.push('/login');
} else {
setIsAuthorized(true);
}
}
}, [user, loading, router]);
if (loading || !isAuthorized) {
return (
<div className="auth-page-container flex items-center justify-center min-h-screen">
<div className="text-xl font-bold">Laddar...</div>
</div>
);
}
return (
<div className="auth-page-container" style={{ paddingTop: '8rem' }}>
<div className="profile-card" style={{ width: '100%', maxWidth: '800px' }}>
<div className="flex justify-between items-center mb-10 border-b border-gray-100 pb-8">
<h2 className="auth-title" style={{ textAlign: 'left', marginBottom: 0 }}>Admin Dashboard</h2>
<Link href="/me" className="auth-button secondary" style={{ width: 'auto', padding: '0.8rem 1.5rem', fontSize: '0.75rem', minHeight: 'auto' }}>
TILLBAKA
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Link href="/admin/products" className="admin-nav-card text-center">
<h4 className="font-black text-xl mb-4 uppercase">Produkter</h4>
<p className="text-sm leading-relaxed">Hantera butikens sortiment</p>
</Link>
<Link href="/admin/categories" className="admin-nav-card text-center">
<h4 className="font-black text-xl mb-4 uppercase">Kategorier</h4>
<p className="text-sm leading-relaxed">Hantera produktkategorier</p>
</Link>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,837 @@
"use client";
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useAuth } from '@/context/AuthContext';
import { useRouter } from 'next/navigation';
import { Plus, Search, Edit, Trash2, Upload, X, Check, Save } from 'lucide-react';
import '@/styles/Auth.css';
import '@/styles/Admin.css';
// Types
interface Product {
id: number;
name: string;
price: number;
original_price?: number;
stock_status: 'in_stock' | 'out_of_stock';
in_stock: boolean; // Computed helper
stock: number;
images: string[];
category_ids: number[];
category_names: string[];
created_at: string;
// New fields
description: string;
width?: number;
height?: number;
depth?: number;
brand?: string;
product_condition: string;
material?: string;
color?: string;
badge_text?: string;
badge_color?: string;
show_on_homepage?: boolean; // New field
}
interface Category {
id: number;
name: string;
}
export default function AdminProductsPage() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
const [products, setProducts] = useState<Product[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
// Sort State
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null);
// Modal State
const [showModal, setShowModal] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [isCreatingCategory, setIsCreatingCategory] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
// Image Preview Modal
const [previewImage, setPreviewImage] = useState<string | null>(null);
// Form State
const [formData, setFormData] = useState({
name: '',
description: '',
price: '',
original_price: '',
stock_status: true, // true = in_stock
stock: '1',
width: '',
height: '',
depth: '',
brand: '',
product_condition: 'Good',
material: '',
color: '',
badge_text: '',
badge_color: '#ef4444', // Default red
show_on_homepage: false,
images: [] as string[],
category_ids: [] as number[]
});
// Load Initial Data
useEffect(() => {
if (!authLoading && user?.role !== 'admin') {
router.push('/login');
return;
}
if (user?.role === 'admin') {
fetchData();
}
}, [user, authLoading, router]);
const fetchData = async () => {
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
const [prodRes, catRes] = await Promise.all([
fetch('/api/admin/products', { headers: { 'Authorization': `Bearer ${token}` } }),
fetch('/api/categories') // Public endpoint usually fine, or create admin one
]);
if (prodRes.ok) {
const data = await prodRes.json();
setProducts(data);
}
if (catRes.ok) {
const data = await catRes.json();
setCategories(data);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
// Form Handlers
const resetForm = () => {
setFormData({
name: '',
description: '',
price: '',
original_price: '',
stock_status: true,
stock: '1',
width: '',
height: '',
depth: '',
brand: '',
product_condition: 'Good',
material: '',
color: '',
badge_text: '',
badge_color: '#ef4444',
show_on_homepage: false,
images: [],
category_ids: []
});
setIsEditing(false);
setEditId(null);
};
const handleOpenAdd = () => {
resetForm();
setShowModal(true);
};
const handleEdit = (product: Product) => {
setFormData({
name: product.name,
description: product.description || '',
price: product.price.toString(),
original_price: product.original_price?.toString() || '',
stock_status: product.in_stock,
stock: product.stock?.toString() || '0',
width: product.width?.toString() || '',
height: product.height?.toString() || '',
depth: product.depth?.toString() || '',
brand: product.brand || '',
product_condition: product.product_condition,
material: product.material || '',
color: product.color || '',
badge_text: product.badge_text || '',
badge_color: product.badge_color || '#ef4444',
show_on_homepage: !!product.show_on_homepage,
images: product.images || [],
category_ids: product.category_ids || []
});
setIsEditing(true);
setEditId(product.id);
setShowModal(true);
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) return;
const uploadData = new FormData();
Array.from(e.target.files).forEach(file => {
uploadData.append('file', file);
});
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
const res = await fetch('/api/admin/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: uploadData
});
if (res.ok) {
const data = await res.json();
setFormData(prev => ({
...prev,
images: [...prev.images, ...data.urls]
}));
}
} catch (_) {
alert('Upload failed');
}
};
const handleRemoveImage = (indexToRemove: number) => {
setFormData(prev => ({
...prev,
images: prev.images.filter((_, i) => i !== indexToRemove)
}));
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
const payload = {
...formData,
stock_status: formData.stock_status, // api expects boolean or enum string mapping?
// Map boolean to string manually or let api handle it if I update api
// API expects: stock_status boolean in my previous thought?
// Let's check API: "stock_status ? 'in_stock' : 'out_of_stock'" OK so boolean is fine in body if var name matches
};
const url = isEditing ? `/api/admin/products/${editId}` : '/api/admin/products';
const method = isEditing ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Failed to save');
await fetchData(); // Refresh list
setShowModal(false);
resetForm();
} catch (_) {
alert('Kunde inte spara produkten');
} finally {
setSaving(false);
}
};
const handleCreateCategory = async () => {
if (!newCategoryName.trim()) return;
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
const res = await fetch('/api/admin/categories', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: newCategoryName })
});
if (res.ok) {
const data = await res.json();
if (data.success && data.category) {
setCategories(prev => [...prev, data.category].sort((a, b) => a.name.localeCompare(b.name)));
setFormData(prev => ({
...prev,
category_ids: [...prev.category_ids, data.category.id]
}));
setNewCategoryName('');
setIsCreatingCategory(false);
}
} else {
alert('Kunde inte skapa kategori');
}
} catch (err) {
console.error(err);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Är du säker på att du vill ta bort den här produkten?')) return;
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
const res = await fetch(`/api/admin/products/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
setProducts(prev => prev.filter(p => p.id !== id));
} else {
alert('Kunde inte radera produkten');
}
} catch (err) {
console.error('Delete error', err);
alert('Ett fel uppstod vid radering');
}
};
const requestSort = (key: string) => {
let direction: 'asc' | 'desc' = 'asc';
if (sortConfig && sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// Filter & Sort Products
const filteredProducts = products.filter(p => {
const searchLower = searchTerm.toLowerCase();
return (
p.name.toLowerCase().includes(searchLower) ||
p.brand?.toLowerCase().includes(searchLower) ||
p.category_names.some(c => c.toLowerCase().includes(searchLower)) ||
p.price.toString().includes(searchLower) ||
p.product_condition.toLowerCase().includes(searchLower)
);
}).sort((a: Product, b: Product) => {
if (!sortConfig) return 0;
let aValue: string | number = '';
let bValue: string | number = '';
if (sortConfig.key === 'category') {
aValue = a.category_names.join('');
bValue = b.category_names.join('');
} else {
const valA = a[sortConfig.key as keyof Product];
const valB = b[sortConfig.key as keyof Product];
aValue = (typeof valA === 'number' || typeof valA === 'string') ? valA : String(valA);
bValue = (typeof valB === 'number' || typeof valB === 'string') ? valB : String(valB);
}
if (aValue < bValue) {
return sortConfig.direction === 'asc' ? -1 : 1;
}
if (aValue > bValue) {
return sortConfig.direction === 'asc' ? 1 : -1;
}
return 0;
});
if (loading) return <div className="p-8 text-center">Laddar adminpanel...</div>;
return (
<div className="admin-container">
<div className="admin-header">
<div className="admin-title-group">
<h1>Produktlager</h1>
<p>Hantera {products.length} produkter i sortimentet</p>
</div>
<div className="admin-actions">
<Link href="/admin" className="auth-button secondary">
Tillbaka
</Link>
<button onClick={handleOpenAdd} className="auth-button">
<Plus size={18} className="mr-2" />
Ny Produkt
</button>
</div>
</div>
<div className="admin-table-container">
<div className="admin-controls">
<div className="relative flex-grow max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
className="search-input pl-10"
placeholder="Sök på namn, märke..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<table className="admin-table">
<thead>
<tr>
<th onClick={() => requestSort('name')}>Produkt {sortConfig?.key === 'name' && (sortConfig.direction === 'asc' ? '↑' : '↓')}</th>
<th onClick={() => requestSort('price')}>Pris {sortConfig?.key === 'price' && (sortConfig.direction === 'asc' ? '↑' : '↓')}</th>
<th onClick={() => requestSort('stock')}>Lager {sortConfig?.key === 'stock' && (sortConfig.direction === 'asc' ? '↑' : '↓')}</th>
<th onClick={() => requestSort('category')}>Kategorier</th>
<th onClick={() => requestSort('product_condition')}>Skick</th>
<th onClick={() => requestSort('created_at')}>Datum {sortConfig?.key === 'created_at' && (sortConfig.direction === 'asc' ? '↑' : '↓')}</th>
<th className="text-right">Åtgärder</th>
</tr>
</thead>
<tbody>
{filteredProducts.map(product => (
<tr key={product.id}>
<td>
<div className="flex items-center justify-between gap-3">
<div className="flex-1">
<div className="font-bold flex items-center gap-2">
{product.name}
{product.badge_text && (
<span
className="text-[10px] uppercase font-bold text-white px-1.5 py-0.5 rounded"
style={{ backgroundColor: product.badge_color || '#ef4444' }}
>
{product.badge_text}
</span>
)}
</div>
<div className="text-xs text-gray-500">{product.brand}</div>
</div>
<div className="flex -space-x-2 overflow-hidden flex-shrink-0">
{product.images.slice(0, 3).map((img, i) => (
<img
key={i}
src={img}
alt=""
className="rounded-full border-2 border-white cursor-pointer hover:scale-110 transition-transform bg-gray-100"
style={{ width: '40px', height: '40px', objectFit: 'cover', minWidth: '40px' }}
onClick={(e) => {
e.stopPropagation();
setPreviewImage(img);
}}
title="Klicka för att förstora"
/>
))}
{product.images.length > 3 && (
<div
className="rounded-full bg-gray-100 border-2 border-white flex items-center justify-center text-xs font-bold text-gray-500"
style={{ width: '40px', height: '40px', minWidth: '40px' }}
>
+{product.images.length - 3}
</div>
)}
</div>
</div>
</td>
<td>{product.price} kr</td>
<td>
{product.in_stock ? (
<span className="status-badge status-instock">I Lager ({product.stock})</span>
) : (
<span className="status-badge status-outstock">Slut</span>
)}
</td>
<td className="text-sm max-w-[200px] truncate">{product.category_names.join(', ')}</td>
<td>{product.product_condition}</td>
<td className="text-sm text-gray-500">{new Date(product.created_at).toLocaleDateString('sv-SE')}</td>
<td className="text-right">
<div className="flex justify-end gap-2">
<button onClick={() => handleEdit(product)} className="p-2 hover:bg-gray-100 rounded text-blue-600">
<Edit size={16} />
</button>
<button
onClick={() => handleDelete(product.id)}
className="p-2 hover:bg-gray-100 rounded text-red-600"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredProducts.length === 0 && (
<div className="p-12 text-center text-gray-500">
Inga produkter hittades.
</div>
)}
</div>
{/* PRODUCT MODAL */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content product-modal bg-[var(--card-bg)] rounded-xl shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b flex justify-between items-center">
<h2 className="text-xl font-bold">{isEditing ? 'Redigera Produkt' : 'Ny Produkt'}</h2>
<button onClick={() => setShowModal(false)} className="p-2 hover:bg-gray-100 rounded-full">
<X size={20} />
</button>
</div>
<div className="modal-body scrollable">
<form onSubmit={handleSave} className="space-y-8">
{/* Section 1: Basic Info */}
<div>
<h3 className="text-sm font-black uppercase tracking-wider text-gray-500 mb-4 border-b pb-2">Grundläggande Information</h3>
<div className="form-grid">
<div className="form-group form-full">
<label className="form-label">Produktnamn *</label>
<input
required
className="form-input"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="form-group form-full">
<label className="form-label">Beskrivning *</label>
<textarea
required
className="form-input min-h-[100px]"
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Märke (Brand)</label>
<input
className="form-input"
value={formData.brand}
onChange={e => setFormData({ ...formData, brand: e.target.value })}
list="brands-list"
/>
<datalist id="brands-list">
<option value="Herman Miller" />
<option value="Kinnarps" />
<option value="IKEA" />
<option value="HÅG" />
</datalist>
</div>
<div className="form-group">
<label className="form-label">Skick *</label>
<select
className="form-input"
value={formData.product_condition}
onChange={e => setFormData({ ...formData, product_condition: e.target.value })}
>
<option value="New">Ny</option>
<option value="Excellent">Utmärkt</option>
<option value="Good">Bra</option>
<option value="Fair">Okej</option>
</select>
</div>
{/* Badge Inputs */}
<div className="form-group">
<label className="form-label">Badge Text (Valfritt)</label>
<input
className="form-input"
placeholder="T.ex. New Arrival, -30%"
value={formData.badge_text}
onChange={e => setFormData({ ...formData, badge_text: e.target.value })}
/>
</div>
<div className="flex items-center px-4 min-h-[3.5rem] border border-[var(--input-border)] bg-[var(--input-bg)] mb-2">
<div className="flex-shrink-0">
<input
type="color"
className="w-10 h-10 p-0 border-0 bg-transparent cursor-pointer rounded overflow-hidden"
value={formData.badge_color}
onChange={e => setFormData({ ...formData, badge_color: e.target.value })}
title="Välj färg"
/>
</div>
<div className="ml-6 flex flex-col justify-center">
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-secondary)] leading-none mb-1">Badge Färg</label>
<span className="text-xs font-mono text-[var(--text-color)] uppercase font-bold">{formData.badge_color}</span>
</div>
</div>
<div className="flex items-center gap-2 mb-2 p-3 border border-[var(--border-color)] bg-[var(--card-bg)] rounded">
<input
type="checkbox"
id="show_on_homepage"
checked={formData.show_on_homepage}
onChange={e => setFormData({ ...formData, show_on_homepage: e.target.checked })}
className="w-4 h-4"
/>
<div className="flex flex-col">
<label htmlFor="show_on_homepage" className="font-bold text-sm select-none cursor-pointer">Visa startsidan</label>
<span className="text-[10px] text-[var(--text-secondary)] uppercase font-bold tracking-wider">Max 8 visas totalt</span>
</div>
</div>
</div>
</div>
{/* Section 2: Pricing & Stock */}
<div>
<h3 className="text-sm font-black uppercase tracking-wider text-gray-500 mb-4 border-b pb-2">Pris & Lager</h3>
<div className="form-grid">
<div className="form-group">
<label className="form-label">Pris (SEK) *</label>
<input
type="number"
required
className="form-input"
value={formData.price}
onChange={e => setFormData({ ...formData, price: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Originalpris (Nypris)</label>
<input
type="number"
className="form-input"
value={formData.original_price}
onChange={e => setFormData({ ...formData, original_price: e.target.value })}
/>
</div>
<div className="form-group">
<div className="flex items-center gap-2 mb-2">
<input
type="checkbox"
id="stock_status"
checked={formData.stock_status}
onChange={e => setFormData({ ...formData, stock_status: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="stock_status" className="font-medium">Finns i lager</label>
</div>
{formData.stock_status && (
<input
type="number"
className="form-input"
placeholder="Antal"
value={formData.stock}
onChange={e => setFormData({ ...formData, stock: e.target.value })}
/>
)}
</div>
</div>
</div>
{/* Section 3: Attributes */}
<div>
<h3 className="text-sm font-black uppercase tracking-wider text-gray-500 mb-4 border-b pb-2">Egenskaper & Mått</h3>
<div className="form-grid">
<div className="grid grid-cols-3 gap-2 form-full">
<div>
<label className="text-xs text-gray-500">Bredd (cm)</label>
<input className="form-input" type="number" placeholder="B" value={formData.width} onChange={e => setFormData({ ...formData, width: e.target.value })} />
</div>
<div>
<label className="text-xs text-gray-500">Höjd (cm)</label>
<input className="form-input" type="number" placeholder="H" value={formData.height} onChange={e => setFormData({ ...formData, height: e.target.value })} />
</div>
<div>
<label className="text-xs text-gray-500">Djup (cm)</label>
<input className="form-input" type="number" placeholder="D" value={formData.depth} onChange={e => setFormData({ ...formData, depth: e.target.value })} />
</div>
</div>
<div className="form-group">
<label className="form-label">Material</label>
<input
className="form-input"
placeholder="T.ex. Ek, Läder..."
value={formData.material}
onChange={e => setFormData({ ...formData, material: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Färg</label>
<input
className="form-input"
placeholder="T.ex. Svart, Vit..."
value={formData.color}
onChange={e => setFormData({ ...formData, color: e.target.value })}
/>
</div>
</div>
</div>
{/* Section 4: Images */}
<div>
<h3 className="text-sm font-black uppercase tracking-wider text-gray-500 mb-4 border-b pb-2">Bilder</h3>
<div className="relative">
<input
type="file"
multiple
accept="image/*"
onChange={handleUpload}
className="hidden"
id="file-upload"
/>
<label htmlFor="file-upload" className="image-upload-area block">
<Upload className="mx-auto mb-2 text-gray-400" size={32} />
<p className="font-medium">Klicka för att ladda upp bilder</p>
<p className="text-xs text-gray-400 mt-1">Stödjer JPG, PNG, WEBP</p>
</label>
</div>
<div className="image-preview-grid">
{formData.images.map((url, idx) => (
<div key={idx} className="image-preview-card">
<img src={url} alt={`Preview ${idx}`} />
<button
type="button"
onClick={() => handleRemoveImage(idx)}
className="remove-image-btn"
>
&times;
</button>
{idx === 0 && (
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-[10px] text-center py-1 font-bold uppercase">
Omslag
</div>
)}
</div>
))}
</div>
</div>
{/* Section 5: Categories */}
<div>
<div className="flex justify-between items-center mb-4 border-b pb-2">
<h3 className="text-sm font-black uppercase tracking-wider text-gray-500">Kategorier</h3>
{!isCreatingCategory ? (
<button
type="button"
onClick={() => setIsCreatingCategory(true)}
className="text-xs font-bold text-blue-600 hover:text-blue-800 flex items-center"
>
<Plus size={14} className="mr-1" />
Skapa Ny
</button>
) : (
<div className="flex items-center gap-2">
<input
autoFocus
className="px-2 py-1 text-xs border rounded w-32"
placeholder="Namn..."
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateCategory();
}
}}
/>
<button
type="button"
onClick={handleCreateCategory}
className="text-green-600 hover:text-green-800"
>
<Check size={16} />
</button>
<button
type="button"
onClick={() => {
setIsCreatingCategory(false);
setNewCategoryName('');
}}
className="text-red-500 hover:text-red-700"
>
<X size={16} />
</button>
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
{categories.map(cat => (
<label key={cat.id} className={`
cursor-pointer px-3 py-1.5 rounded-full text-sm font-medium border transition-all
${formData.category_ids.includes(cat.id)
? 'bg-black text-white border-black'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-400'}
`}>
<input
type="checkbox"
className="hidden"
checked={formData.category_ids.includes(cat.id)}
onChange={(e) => {
const checked = e.target.checked;
setFormData(prev => ({
...prev,
category_ids: checked
? [...prev.category_ids, cat.id]
: prev.category_ids.filter(id => id !== cat.id)
}));
}}
/>
{cat.name}
</label>
))}
</div>
</div>
<div className="sticky bottom-0 flex justify-end gap-3 z-10">
<button
type="button"
onClick={() => setShowModal(false)}
className="auth-button secondary w-auto px-6"
>
Avbryt
</button>
<button
type="submit"
disabled={saving}
className="auth-button success w-auto px-6"
>
<Save size={18} className="mr-2" />
{saving ? 'Sparar...' : isEditing ? 'Uppdatera' : 'Spara Produkt'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* IMAGE PREVIEW MODAL */}
{previewImage && (
<div
className="modal-overlay z-[2000]"
onClick={() => setPreviewImage(null)}
>
<div className="relative max-w-4xl max-h-[90vh] p-2">
<button
className="absolute -top-10 right-0 text-white hover:text-gray-300 transition-colors"
onClick={() => setPreviewImage(null)}
>
<X size={32} />
</button>
<img
src={previewImage}
alt="Full preview"
className="max-w-full max-h-[90vh] object-contain rounded shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,159 @@
import { NextResponse } from 'next/server';
import pool from '@/lib/db';
import { requireAdmin, AuthenticatedRequest } from '@/lib/middleware';
import { UpdateCategoryRequest } from '@/types/api';
import { RowDataPacket, ResultSetHeader } from 'mysql2';
// GET single category
async function getHandler(
req: AuthenticatedRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const [categories] = await pool.query<RowDataPacket[]>(
'SELECT * FROM categories WHERE id = ?',
[id]
);
if (categories.length === 0) {
return NextResponse.json(
{ error: 'Category not found' },
{ status: 404 }
);
}
return NextResponse.json(categories[0]);
} catch (error) {
console.error('Get category error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// PUT update category
async function putHandler(
req: AuthenticatedRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body: UpdateCategoryRequest = await req.json();
const { name, description } = body;
// Check if category exists
const [existing] = await pool.query<RowDataPacket[]>(
'SELECT * FROM categories WHERE id = ?',
[id]
);
if (existing.length === 0) {
return NextResponse.json(
{ error: 'Category not found' },
{ status: 404 }
);
}
// Build update query dynamically
const updates: string[] = [];
const values: (string | number | null)[] = [];
if (name !== undefined) {
updates.push('name = ?');
values.push(name);
}
if (description !== undefined) {
updates.push('description = ?');
values.push(description);
}
if (body.image_url !== undefined) {
updates.push('image_url = ?');
values.push(body.image_url || null);
}
if (body.show_on_homepage !== undefined) {
updates.push('show_on_homepage = ?');
values.push(body.show_on_homepage ? 1 : 0);
}
if (updates.length === 0) {
return NextResponse.json(
{ error: 'No fields to update' },
{ status: 400 }
);
}
values.push(id);
await pool.query(
`UPDATE categories SET ${updates.join(', ')} WHERE id = ?`,
values
);
// Fetch updated category
const [updated] = await pool.query<RowDataPacket[]>(
'SELECT * FROM categories WHERE id = ?',
[id]
);
return NextResponse.json(updated[0]);
} catch (error) {
console.error('Update category error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// DELETE category
async function deleteHandler(
req: AuthenticatedRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Check if category has products
const [products] = await pool.query<RowDataPacket[]>(
'SELECT COUNT(*) as count FROM products WHERE category_id = ?',
[id]
);
if (products[0].count > 0) {
return NextResponse.json(
{ error: 'Cannot delete category with existing products' },
{ status: 409 }
);
}
// Delete category
const [result] = await pool.query<ResultSetHeader>(
'DELETE FROM categories WHERE id = ?',
[id]
);
if (result.affectedRows === 0) {
return NextResponse.json(
{ error: 'Category not found' },
{ status: 404 }
);
}
return NextResponse.json({ message: 'Category deleted successfully' });
} catch (error) {
console.error('Delete category error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export const GET = requireAdmin(getHandler);
export const PUT = requireAdmin(putHandler);
export const DELETE = requireAdmin(deleteHandler);

View File

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { AuthenticatedRequest } from '@/lib/middleware';
import pool from '@/lib/db';
import { ResultSetHeader } from 'mysql2';
async function handler(req: AuthenticatedRequest) {
try {
const { name, description, image_url, show_on_homepage } = await req.json();
if (!name || name.length < 2) {
return NextResponse.json({ error: 'Name too short' }, { status: 400 });
}
const [result] = await pool.query<ResultSetHeader>(
'INSERT INTO categories (name, description, image_url, show_on_homepage) VALUES (?, ?, ?, ?)',
[name, description || null, image_url || null, show_on_homepage ? 1 : 0]
);
return NextResponse.json({
success: true,
category: { id: result.insertId, name, description, image_url, show_on_homepage }
});
} catch (error) {
console.error('Create category error:', error);
return NextResponse.json({ error: 'Failed to create category' }, { status: 500 });
}
}
import { requireAdmin } from '@/lib/middleware';
export const POST = requireAdmin(handler);

View File

@ -0,0 +1,100 @@
import { NextResponse } from 'next/server';
import { AuthenticatedRequest } from '@/lib/middleware';
import pool from '@/lib/db';
import { ResultSetHeader } from 'mysql2';
async function updateHandler(req: AuthenticatedRequest, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
const connection = await pool.getConnection();
await connection.beginTransaction();
try {
const body = await req.json();
const {
name, description, price, original_price,
stock_status, stock,
width, height, depth,
brand, product_condition, material, color,
category_ids, images,
badge_text, badge_color, show_on_homepage
} = body;
// 1. Update Product
await connection.query(`
UPDATE products SET
category_id = ?,
name = ?, description = ?, price = ?, original_price = ?,
stock_status = ?, stock = ?,
width = ?, height = ?, depth = ?,
brand = ?, product_condition = ?, material = ?, color = ?,
badge_text = ?, badge_color = ?, show_on_homepage = ?
WHERE id = ?
`, [
category_ids?.[0] || 1,
name, description, price, original_price || null,
stock_status ? 'in_stock' : 'out_of_stock', stock || 0,
width || null, height || null, depth || null,
brand || null, product_condition, material || null, color || null,
badge_text || null, badge_color || null, show_on_homepage ? 1 : 0,
id
]);
// 2. Update Images (Strategy: Delete all and re-insert)
// A better strategy would be to diff, but this is simpler for MVP
if (images) {
await connection.query('DELETE FROM product_images WHERE product_id = ?', [id]);
if (images.length > 0) {
const imageValues = images.map((url: string, index: number) =>
[id, url, index, index === 0]
);
await connection.query(
`INSERT INTO product_images (product_id, image_url, display_order, is_primary) VALUES ?`,
[imageValues]
);
}
}
// 3. Update Categories (Strategy: Delete all and re-insert)
if (category_ids) {
await connection.query('DELETE FROM product_categories WHERE product_id = ?', [id]);
if (category_ids.length > 0) {
const categoryValues = category_ids.map((catId: number) =>
[id, catId]
);
await connection.query(
`INSERT INTO product_categories (product_id, category_id) VALUES ?`,
[categoryValues]
);
}
}
await connection.commit();
return NextResponse.json({ success: true, message: 'Produkten har uppdaterats' });
} catch (error) {
await connection.rollback();
console.error('Update product error:', error);
return NextResponse.json({ error: 'Failed to update product' }, { status: 500 });
} finally {
connection.release();
}
}
async function deleteHandler(req: AuthenticatedRequest, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
try {
await pool.query('DELETE FROM products WHERE id = ?', [id]);
return NextResponse.json({ success: true, message: 'Produkten har raderats' });
} catch (error) {
console.error('Delete product error:', error);
return NextResponse.json({ error: 'Failed to delete product' }, { status: 500 });
}
}
import { requireAdmin } from '@/lib/middleware';
export const PUT = requireAdmin(updateHandler);
export const DELETE = requireAdmin(deleteHandler);

View File

@ -0,0 +1,113 @@
import { NextResponse } from 'next/server';
import { AuthenticatedRequest } from '@/lib/middleware';
import pool from '@/lib/db';
import { ResultSetHeader, RowDataPacket } from 'mysql2';
// GET: List all products with images and categories
async function getHandler(req: AuthenticatedRequest) {
try {
// Fetch products
const [products] = await pool.query<RowDataPacket[]>(`
SELECT p.*,
GROUP_CONCAT(DISTINCT pi.image_url ORDER BY pi.display_order ASC) as images,
GROUP_CONCAT(DISTINCT c.id) as category_ids,
GROUP_CONCAT(DISTINCT c.name) as category_names
FROM products p
LEFT JOIN product_images pi ON p.id = pi.product_id
LEFT JOIN product_categories pc ON p.id = pc.product_id
LEFT JOIN categories c ON pc.category_id = c.id
GROUP BY p.id
ORDER BY p.created_at DESC
`);
// Format the output
const formattedProducts = products.map(p => ({
...p,
images: p.images ? p.images.split(',') : [],
category_ids: p.category_ids ? p.category_ids.split(',').map(Number) : [],
category_names: p.category_names ? p.category_names.split(',') : [],
in_stock: p.stock_status === 'in_stock',
price: Number(p.price),
original_price: p.original_price ? Number(p.original_price) : null
}));
return NextResponse.json(formattedProducts);
} catch (error) {
console.error('Fetch products error:', error);
return NextResponse.json({ error: 'Failed to fetch products' }, { status: 500 });
}
}
// POST: Create a new product
async function postHandler(req: AuthenticatedRequest) {
const connection = await pool.getConnection();
await connection.beginTransaction();
try {
const body = await req.json();
const {
name, description, price, original_price,
stock_status, stock,
width, height, depth,
brand, product_condition, material, color,
category_ids, images,
badge_text, badge_color, show_on_homepage
} = body;
// 1. Insert Product
const [result] = await connection.query<ResultSetHeader>(`
INSERT INTO products (
category_id, name, description, price, original_price,
stock_status, stock, width, height, depth,
brand, product_condition, material, color,
badge_text, badge_color, show_on_homepage
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
category_ids?.[0] || 1, // Fallback to category 1 if none provided
name, description, price, original_price || null,
stock_status ? 'in_stock' : 'out_of_stock', stock || 0,
width || null, height || null, depth || null,
brand || null, product_condition, material || null, color || null,
badge_text || null, badge_color || null, show_on_homepage ? 1 : 0
]);
const productId = result.insertId;
// 2. Insert Images
if (images && images.length > 0) {
const imageValues = images.map((url: string, index: number) =>
[productId, url, index, index === 0]
);
await connection.query(
`INSERT INTO product_images (product_id, image_url, display_order, is_primary) VALUES ?`,
[imageValues]
);
}
// 3. Insert Categories
if (category_ids && category_ids.length > 0) {
const categoryValues = category_ids.map((catId: number) =>
[productId, catId]
);
await connection.query(
`INSERT INTO product_categories (product_id, category_id) VALUES ?`,
[categoryValues]
);
}
await connection.commit();
return NextResponse.json({ success: true, productId, message: 'Produkten har skapats' });
} catch (error) {
await connection.rollback();
console.error('Create product error:', error);
return NextResponse.json({ error: 'Failed to create product' }, { status: 500 });
} finally {
connection.release();
}
}
import { requireAdmin } from '@/lib/middleware';
export const GET = requireAdmin(getHandler);
export const POST = requireAdmin(postHandler);

View File

@ -0,0 +1,48 @@
import { NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
import { authenticateToken, AuthenticatedRequest } from '@/lib/middleware';
// Helper to ensure directory exists
async function ensureDir(dirPath: string) {
try {
await mkdir(dirPath, { recursive: true });
} catch (error) {
// Ignore if exists
}
}
async function handler(req: AuthenticatedRequest) {
if (req.user?.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
try {
const formData = await req.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
}
const uploadDir = path.join(process.cwd(), 'public', 'uploads', 'categories');
await ensureDir(uploadDir);
const buffer = Buffer.from(await file.arrayBuffer());
// Create unique filename
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.name) || '.jpg';
const filename = `category-${uniqueSuffix}${ext}`;
const filepath = path.join(uploadDir, filename);
await writeFile(filepath, buffer);
const url = `/uploads/categories/${filename}`;
return NextResponse.json({ url });
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}
export const POST = authenticateToken(handler);

View File

@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
import { authenticateToken, AuthenticatedRequest } from '@/lib/middleware';
// Helper to ensure directory exists
async function ensureDir(dirPath: string) {
try {
await mkdir(dirPath, { recursive: true });
} catch (error) {
// Ignore if exists
}
}
async function handler(req: AuthenticatedRequest) {
if (req.user?.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
try {
const formData = await req.formData();
const files = formData.getAll('file') as File[];
if (!files || files.length === 0) {
return NextResponse.json({ error: 'No files uploaded' }, { status: 400 });
}
const uploadDir = path.join(process.cwd(), 'public', 'uploads', 'products');
await ensureDir(uploadDir);
const uploadedUrls: string[] = [];
for (const file of files) {
const buffer = Buffer.from(await file.arrayBuffer());
// Create unique filename
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.name) || '.jpg';
const filename = `product-${uniqueSuffix}${ext}`;
const filepath = path.join(uploadDir, filename);
await writeFile(filepath, buffer);
uploadedUrls.push(`/uploads/products/${filename}`);
}
return NextResponse.json({ urls: uploadedUrls });
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}
export const POST = authenticateToken(handler);

View File

@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { authenticateToken, AuthenticatedRequest } from '@/lib/middleware';
import pool from '@/lib/db';
import { ResultSetHeader } from 'mysql2';
async function handler(req: AuthenticatedRequest) {
try {
if (!req.user) {
return NextResponse.json({ error: 'Obehörig' }, { status: 401 });
}
// Disable 2FA by resetting enabled flag and secret
await pool.query<ResultSetHeader>(
'UPDATE users SET two_factor_enabled = FALSE, two_factor_secret = NULL WHERE id = ?',
[req.user.userId]
);
return NextResponse.json({
success: true,
message: 'Tvåfaktorsautentisering har inaktiverats framgångsrikt.'
});
} catch (error) {
console.error('Disable 2FA error:', error);
return NextResponse.json({ error: 'Internt serverfel vid inaktivering av 2FA' }, { status: 500 });
}
}
export const POST = authenticateToken(handler);

View File

@ -0,0 +1,92 @@
import { NextResponse } from 'next/server';
import pool from '@/lib/db';
import { verify as verifyTOTP } from 'otplib'; // Use functional verify from otplib
import { generateToken } from '@/lib/auth'; // Our own generateToken
import { RowDataPacket } from 'mysql2';
export async function POST(req: Request) {
try {
const { userId, code } = await req.json();
if (!userId || !code) {
return NextResponse.json(
{ error: 'Användar-ID och verifieringskod krävs' },
{ status: 400 }
);
}
const [users] = await pool.query<RowDataPacket[]>(
'SELECT * FROM users WHERE id = ?',
[userId]
);
if (users.length === 0) {
return NextResponse.json(
{ error: 'Användaren hittades inte' },
{ status: 404 }
);
}
const user = users[0];
if (!user.two_factor_enabled || !user.two_factor_secret) {
return NextResponse.json(
{ error: '2FA är inte aktiverat för detta konto' },
{ status: 400 }
);
}
const secret = user.two_factor_secret;
const cleanCode = code.replace(/\s/g, '');
console.log(`[2FA Login Debug] User: ${userId}, Code: ${cleanCode}`);
// Functional verify(options) in v13 returns Promise<VerifyResult>
const result = await verifyTOTP({
token: cleanCode,
secret: secret,
});
console.log(`[2FA Login Debug] Result: ${result.valid}`);
if (!result.valid) {
return NextResponse.json(
{ error: 'Ogiltig verifieringskod' },
{ status: 401 }
);
}
// Generate final token
const token = generateToken({
userId: user.id,
email: user.email,
role: user.role,
});
return NextResponse.json({
token,
user: {
id: user.id,
email: user.email,
name: user.full_name || user.username,
role: user.role,
created_at: user.created_at,
email_verified: !!user.email_verified,
two_factor_enabled: !!user.two_factor_enabled,
newsletter_subscribed: !!user.newsletter_subscribed,
personnummer: user.personnummer,
mobile: user.mobile,
address: user.address,
zip_code: user.zip_code,
city: user.city,
country: user.country
}
});
} catch (error) {
console.error('2FA login error:', error);
return NextResponse.json(
{ error: 'Internt serverfel' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { authenticateToken, AuthenticatedRequest } from '@/lib/middleware';
import { generateSecret, generateURI } from 'otplib'; // Use functional exports
import QRCode from 'qrcode';
import pool from '@/lib/db';
import { ResultSetHeader } from 'mysql2';
async function handler(req: AuthenticatedRequest) {
try {
if (!req.user) {
return NextResponse.json({ error: 'Obehörig' }, { status: 401 });
}
const userEmail = req.user.email;
// functional generateSecret()
const secret = generateSecret();
// functional generateURI(options)
const otpauth = generateURI({
secret,
label: userEmail,
issuer: 'Nordic Storium',
});
const qrCodeDataUrl = await QRCode.toDataURL(otpauth);
console.log(`[2FA Setup Debug] User: ${req.user.userId}, Secret Generated: ${secret}`);
// Store the secret temporarily (unverified).
await pool.query<ResultSetHeader>(
'UPDATE users SET two_factor_secret = ? WHERE id = ?',
[secret, req.user.userId]
);
return NextResponse.json({
qrCodeDataUrl,
secret,
});
} catch (error) {
console.error('2FA setup error:', error);
return NextResponse.json({ error: 'Internt serverfel vid 2FA-konfiguration' }, { status: 500 });
}
}
export const GET = authenticateToken(handler);

View File

@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { authenticateToken, AuthenticatedRequest } from '@/lib/middleware';
import { verify as verifyTOTP } from 'otplib'; // Use functional verify
import pool from '@/lib/db';
import { RowDataPacket, ResultSetHeader } from 'mysql2';
async function handler(req: AuthenticatedRequest) {
try {
if (!req.user) {
return NextResponse.json({ error: 'Obehörig' }, { status: 401 });
}
const { code } = await req.json();
if (!code) {
return NextResponse.json({ error: 'Verifieringskod krävs' }, { status: 400 });
}
// Get user's secret
const [users] = await pool.query<RowDataPacket[]>(
'SELECT two_factor_secret FROM users WHERE id = ?',
[req.user.userId]
);
if (users.length === 0 || !users[0].two_factor_secret) {
return NextResponse.json({ error: '2FA-konfiguration hittades inte' }, { status: 404 });
}
const secret = users[0].two_factor_secret;
const cleanCode = code.replace(/\s/g, '');
console.log(`[2FA Verify Debug] User: ${req.user.userId}, Code: ${cleanCode}`);
// Functional verify(options) in v13
const result = await verifyTOTP({
token: cleanCode,
secret: secret,
});
console.log(`[2FA Verify Debug] Result: ${result.valid}`);
if (!result.valid) {
return NextResponse.json({ error: 'Ogiltig verifieringskod' }, { status: 400 });
}
// Enable 2FA
await pool.query<ResultSetHeader>(
'UPDATE users SET two_factor_enabled = TRUE WHERE id = ?',
[req.user.userId]
);
return NextResponse.json({
success: true,
message: '2FA har aktiverats framgångsrikt'
});
} catch (error) {
console.error('2FA verification error:', error);
return NextResponse.json({ error: 'Internt serverfel vid 2FA-verifiering' }, { status: 500 });
}
}
export const POST = authenticateToken(handler);

View File

@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { authenticateToken, AuthenticatedRequest } from '@/lib/middleware';
import pool from '@/lib/db';
import { ResultSetHeader } from 'mysql2';
async function handler(req: AuthenticatedRequest) {
try {
if (!req.user) {
return NextResponse.json({ error: 'Obehörig' }, { status: 401 });
}
// Delete user account (CASCADE will handle user_sessions)
await pool.query<ResultSetHeader>(
'DELETE FROM users WHERE id = ?',
[req.user.userId]
);
return NextResponse.json({
success: true,
message: 'Ditt konto har tagits bort framgångsrikt.'
});
} catch (error) {
console.error('Delete account error:', error);
return NextResponse.json({ error: 'Internt serverfel vid borttagning av konto' }, { status: 500 });
}
}
export const DELETE = authenticateToken(handler);

View File

@ -0,0 +1,60 @@
import { NextResponse } from 'next/server';
import { authenticateToken, AuthenticatedRequest } from '@/lib/middleware';
import pool from '@/lib/db';
import { hashPassword, comparePassword } from '@/lib/auth';
import { RowDataPacket, ResultSetHeader } from 'mysql2';
async function handler(req: AuthenticatedRequest) {
try {
if (!req.user) {
return NextResponse.json({ error: 'Obehörig' }, { status: 401 });
}
const { currentPassword, newPassword } = await req.json();
if (!newPassword) {
return NextResponse.json({ error: 'Nytt lösenord krävs' }, { status: 400 });
}
if (newPassword.length < 6) {
return NextResponse.json({ error: 'Det nya lösenordet måste vara minst 6 tecken' }, { status: 400 });
}
// Check current password
const [users] = await pool.query<RowDataPacket[]>(
'SELECT password_hash FROM users WHERE id = ?',
[req.user.userId]
);
if (users.length === 0) {
return NextResponse.json({ error: 'Användaren hittades inte' }, { status: 404 });
}
const user = users[0];
// If user has a password, they must provide the correct current one
if (user.password_hash) {
if (!currentPassword) {
return NextResponse.json({ error: 'Nuvarande lösenord krävs' }, { status: 400 });
}
const isValid = await comparePassword(currentPassword, user.password_hash);
if (!isValid) {
return NextResponse.json({ error: 'Ditt nuvarande lösenord är felaktigt' }, { status: 400 });
}
}
// Hash and update
const newHash = await hashPassword(newPassword);
await pool.query<ResultSetHeader>(
'UPDATE users SET password_hash = ? WHERE id = ?',
[newHash, req.user.userId]
);
return NextResponse.json({ success: true, message: 'Ditt lösenord har uppdaterats.' });
} catch (error) {
console.error('Change password error:', error);
return NextResponse.json({ error: 'Internt serverfel' }, { status: 500 });
}
}
export const POST = authenticateToken(handler);

View File

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { RowDataPacket } from 'mysql2';
import crypto from 'crypto';
export async function POST(req: NextRequest) {
try {
const { emailOrUsername } = await req.json();
if (!emailOrUsername) {
return NextResponse.json({ error: 'E-post eller användarnamn krävs' }, { status: 400 });
}
// Find user
const [users] = await pool.query<RowDataPacket[]>(
'SELECT id, email, username FROM users WHERE email = ? OR username = ?',
[emailOrUsername, emailOrUsername]
);
if (users.length === 0) {
// Generic success message for security
return NextResponse.json({
success: true,
message: 'Om ett konto finns hos oss har vi skickat en återställningslänk.'
});
}
const user = users[0];
const resetToken = crypto.randomBytes(32).toString('hex');
// Store reset token (re-using verification_token field or adding a new column)
// For simplicity, we'll use verification_token if available or update it.
// Ideally, a separate password_reset_token table would be better, but let's check init.sql again.
// init.sql has verification_token. Let's use it or add a column if needed.
// Actually, let's just use verification_token for now.
await pool.query(
'UPDATE users SET verification_token = ? WHERE id = ?',
[resetToken, user.id]
);
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password/${resetToken}`;
// Send email
// Send email
const { sendPasswordResetEmail } = await import('@/lib/email');
await sendPasswordResetEmail(user.email, resetToken);
return NextResponse.json({
success: true,
message: 'Om ett konto finns hos oss har vi skickat en återställningslänk.'
});
} catch (error) {
console.error('Forgot password error:', error);
return NextResponse.json({ error: 'Internt serverfel' }, { status: 500 });
}
}

View File

@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
import { getGoogleUser } from '@/lib/google-auth';
import pool from '@/lib/db';
import { generateToken } from '@/lib/auth';
import { RowDataPacket, ResultSetHeader } from 'mysql2';
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams;
const code = searchParams.get('code');
if (!code) {
return NextResponse.redirect(new URL('/login?error=GoogleAuthFailed', req.url));
}
try {
const googleUser = await getGoogleUser(code);
const { email, name, sub: googleId } = googleUser;
// Check if user exists
const [users] = await pool.query<RowDataPacket[]>(
'SELECT * FROM users WHERE email = ?',
[email]
);
let user;
if (users.length > 0) {
user = users[0];
// Update google_id if not set
if (!user.google_id) {
await pool.query('UPDATE users SET google_id = ?, email_verified = 1 WHERE id = ?', [googleId, user.id]);
}
} else {
// Create new user
const username = email.split('@')[0] + Math.random().toString(36).substring(7);
const [result] = await pool.query<ResultSetHeader>(
`INSERT INTO users (email, username, full_name, google_id, email_verified, role)
VALUES (?, ?, ?, ?, 1, 'user')`,
[email, username, name, googleId]
);
const [newUsers] = await pool.query<RowDataPacket[]>(
'SELECT * FROM users WHERE id = ?',
[result.insertId]
);
user = newUsers[0];
}
// Generate token
const token = generateToken({
userId: user.id,
email: user.email,
role: user.role,
}, '7d'); // Long expiry for OAuth usually fine, or check rememberMe logic
// Redirect to a handling page on the frontend
const redirectUrl = new URL('/login', req.url);
redirectUrl.searchParams.set('token', token);
// Only pass essential auth data in the URL to protect PII
// Full profile data will be fetched securely by the frontend via /api/auth/me
const safeUser = {
id: user.id,
email: user.email,
username: user.username,
name: user.full_name || user.username,
role: user.role,
has_password: !!user.password_hash
};
redirectUrl.searchParams.set('user', JSON.stringify(safeUser));
return NextResponse.redirect(redirectUrl);
} catch (error) {
console.error('Google Auth Callback Error:', error);
return NextResponse.redirect(new URL('/login?error=GoogleAuthFailed', req.url));
}
}

View File

@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { getGoogleAuthUrl } from '@/lib/google-auth';
export async function GET() {
try {
const url = getGoogleAuthUrl();
return NextResponse.json({ url });
} catch (error) {
console.error('Failed to generate Google Auth URL:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { comparePassword, generateToken } from '@/lib/auth';
import { LoginRequest, AuthResponse } from '@/types/api';
import { RowDataPacket } from 'mysql2';
export async function POST(req: NextRequest) {
try {
const body: LoginRequest = await req.json();
const { email, password, rememberMe } = body;
// Validation
if (!email || !password) {
return NextResponse.json(
{ error: 'E-post och lösenord krävs' },
{ status: 400 }
);
}
// Find user by email or username
const [users] = await pool.query<RowDataPacket[]>(
'SELECT * FROM users WHERE email = ? OR username = ?',
[email, email]
);
if (users.length === 0) {
return NextResponse.json(
{ error: 'Ogiltig e-post/användarnamn eller lösenord' },
{ status: 401 }
);
}
const user = users[0];
// Verify password (safely handle accounts with no password like Google-only accounts)
const isValidPassword = user.password_hash ? await comparePassword(password, user.password_hash) : false;
if (!isValidPassword) {
return NextResponse.json(
{ error: 'Ogiltig e-post/användarnamn eller lösenord' },
{ status: 401 }
);
}
// Check for email verification
if (!user.email_verified) {
return NextResponse.json(
{
error: 'Din e-postadress är inte verifierad än. Vänligen kontrollera din inkorg.',
unverified: true,
email: user.email
},
{ status: 403 }
);
}
// Check for 2FA
if (user.two_factor_enabled) {
return NextResponse.json({
twoFactorRequired: true,
userId: user.id
});
}
// Generate token with variable expiry
const token = generateToken(
{
userId: user.id,
email: user.email,
role: user.role,
},
rememberMe ? '30d' : '2h'
);
return NextResponse.json({
token,
user: {
id: user.id,
email: user.email,
username: user.username,
name: user.full_name || user.username,
role: user.role,
created_at: user.created_at,
email_verified: !!user.email_verified,
two_factor_enabled: !!user.two_factor_enabled,
newsletter_subscribed: !!user.newsletter_subscribed,
personnummer: user.personnummer,
mobile: user.mobile,
address: user.address,
zip_code: user.zip_code,
city: user.city,
country: user.country,
has_password: !!user.password_hash
}
});
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Internt serverfel' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,103 @@
import { NextResponse } from 'next/server';
import { authenticateToken, AuthenticatedRequest } from '@/lib/middleware';
import pool from '@/lib/db';
import { RowDataPacket, ResultSetHeader } from 'mysql2';
import { validateSwedishMobile, normalizeSwedishMobile, validatePersonnummer, validateAddress } from '@/lib/validation';
async function getHandler(req: AuthenticatedRequest) {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const [users] = await pool.query<RowDataPacket[]>(
'SELECT id, email, username, full_name, role, created_at, personnummer, mobile, address, zip_code, city, country, email_verified, newsletter_subscribed, two_factor_enabled, password_hash FROM users WHERE id = ?',
[req.user.userId]
);
if (users.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const user = users[0];
return NextResponse.json({
user: {
id: user.id,
email: user.email,
username: user.username,
name: user.full_name,
role: user.role,
created_at: user.created_at,
personnummer: user.personnummer,
mobile: user.mobile,
address: user.address,
zip_code: user.zip_code,
city: user.city,
country: user.country,
email_verified: Boolean(user.email_verified),
newsletter_subscribed: Boolean(user.newsletter_subscribed),
two_factor_enabled: Boolean(user.two_factor_enabled),
has_password: !!user.password_hash
},
});
} catch (error) {
console.error('Get me error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
async function patchHandler(req: AuthenticatedRequest) {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { full_name, personnummer, mobile, address, zip_code, city, country } = body;
// Validation
if (full_name && full_name.length < 2) {
return NextResponse.json({ error: 'Namnet är för kort.' }, { status: 400 });
}
if (personnummer && !validatePersonnummer(personnummer)) {
return NextResponse.json({ error: 'Ogiltigt personnummer. Använd formatet YYYYMMDD-XXXX.' }, { status: 400 });
}
let normalizedMobile = mobile;
if (mobile) {
if (!validateSwedishMobile(mobile)) {
return NextResponse.json({ error: 'Ogiltigt mobilnummer. Ange ett giltigt svenskt mobilnummer.' }, { status: 400 });
}
normalizedMobile = normalizeSwedishMobile(mobile);
}
if (address && !validateAddress(address)) {
return NextResponse.json({ error: 'Ogiltig adress. Ange gatuadress och nummer.' }, { status: 400 });
}
const [result] = await pool.query<ResultSetHeader>(
`UPDATE users SET
full_name = COALESCE(?, full_name),
personnummer = COALESCE(?, personnummer),
mobile = COALESCE(?, mobile),
address = COALESCE(?, address),
zip_code = COALESCE(?, zip_code),
city = COALESCE(?, city)
WHERE id = ?`,
[full_name, personnummer, normalizedMobile, address, zip_code, city, req.user.userId]
);
return NextResponse.json({
success: true,
message: 'Profilen har uppdaterats.'
});
} catch (error) {
console.error('Update profile error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export const GET = authenticateToken(getHandler);
export const PATCH = authenticateToken(patchHandler);

View File

@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import { authenticateToken, AuthenticatedRequest } from '@/lib/middleware';
import pool from '@/lib/db';
import { ResultSetHeader } from 'mysql2';
async function handler(req: AuthenticatedRequest) {
try {
if (!req.user) {
return NextResponse.json({ error: 'Obehörig' }, { status: 401 });
}
const { subscribed } = await req.json();
// Update newsletter status
await pool.query<ResultSetHeader>(
'UPDATE users SET newsletter_subscribed = ? WHERE id = ?',
[subscribed, req.user.userId]
);
if (subscribed) {
const [users] = await pool.query<import('mysql2').RowDataPacket[]>(
'SELECT full_name, username FROM users WHERE id = ?',
[req.user.userId]
);
if (users.length > 0) {
const { sendWelcomeNewsletterEmail } = await import('@/lib/email');
const name = users[0].full_name || users[0].username;
sendWelcomeNewsletterEmail(req.user.email, name).catch(err => console.error('Failed to send newsletter welcome email:', err));
}
}
return NextResponse.json({
success: true,
message: subscribed ? 'Du prenumererar nu på nyhetsbrevet.' : 'Du har avbrutit din prenumeration på nyhetsbrevet.'
});
} catch (error) {
console.error('Newsletter toggle error:', error);
return NextResponse.json({ error: 'Internt serverfel vid uppdatering av nyhetsbrev' }, { status: 500 });
}
}
export const POST = authenticateToken(handler);

View File

@ -0,0 +1,128 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { hashPassword } from '@/lib/auth';
import { RowDataPacket, ResultSetHeader } from 'mysql2';
import crypto from 'crypto';
import { validatePersonnummer, validateAddress, lookupSwedishAddress, validateSwedishMobile } from '@/lib/validation';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { email, username, password, name, newsletter, personnummer, mobile, address, zip_code, city, country } = body;
if (!email || !username || !password || !name || !personnummer || !address || !zip_code || !city) {
return NextResponse.json(
{ error: 'Alla obligatoriska fält (inklusive adress och personnummer) krävs' },
{ status: 400 }
);
}
// Validate Personnummer (Luhn + Date)
if (!validatePersonnummer(personnummer)) {
return NextResponse.json(
{ error: 'Personnummer är ogiltigt. Kontrollera formatet ÅÅÅÅMMDD-XXXX.' },
{ status: 400 }
);
}
// Validate Address Format
if (!validateAddress(address)) {
return NextResponse.json(
{ error: 'Ogiltigt adressformat. Ange gatunamn och nummer.' },
{ status: 400 }
);
}
// Verify physical address exists (External API)
const addressExists = await lookupSwedishAddress(address, zip_code, city);
if (!addressExists) {
return NextResponse.json(
{ error: 'Adressen verkar inte existera. Vänligen kontrollera stavning och postnummer.' },
{ status: 400 }
);
}
// Validate Mobile Number
if (!validateSwedishMobile(mobile)) {
return NextResponse.json(
{ error: 'Ogiltigt mobilnummer. Ange ett giltigt svenskt mobilnummer.' },
{ status: 400 }
);
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Ogiltigt e-postformat' },
{ status: 400 }
);
}
if (username.length < 3) {
return NextResponse.json(
{ error: 'Användarnamnet måste vara minst 3 tecken långt' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Lösenordet måste vara minst 6 tecken långt' },
{ status: 400 }
);
}
const [existingUsers] = await pool.query<RowDataPacket[]>(
'SELECT id FROM users WHERE email = ? OR username = ?',
[email, username]
);
if (existingUsers.length > 0) {
return NextResponse.json(
{ error: 'En användare med denna e-post eller användarnamn finns redan' },
{ status: 409 }
);
}
const [userCount] = await pool.query<RowDataPacket[]>(
'SELECT COUNT(*) as count FROM users'
);
const isFirstUser = userCount[0].count === 0;
const role = isFirstUser ? 'admin' : 'user';
const passwordHash = await hashPassword(password);
const verificationToken = crypto.randomBytes(32).toString('hex');
const [result] = await pool.query<ResultSetHeader>(
'INSERT INTO users (email, username, password_hash, full_name, role, newsletter_subscribed, verification_token, personnummer, mobile, address, zip_code, city, country) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[email, username, passwordHash, name, role, newsletter || false, verificationToken, personnummer, mobile, address, zip_code, city, country || 'Sverige']
);
const userId = result.insertId;
const { sendVerificationEmail, sendWelcomeNewsletterEmail } = await import('@/lib/email');
sendVerificationEmail(email, verificationToken).catch(err => console.error('Failed to send verification email:', err));
if (newsletter) {
sendWelcomeNewsletterEmail(email, name).catch(err => console.error('Failed to send newsletter welcome email:', err));
}
return NextResponse.json({
message: 'Registreringen lyckades! Vänligen kontrollera din e-post för att verifiera ditt konto.',
user: {
id: userId,
email,
name,
role,
created_at: new Date(),
email_verified: false
}
}, { status: 201 });
} catch (error) {
console.error('Registration error:', error);
return NextResponse.json(
{ error: 'Internt serverfel' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { RowDataPacket, ResultSetHeader } from 'mysql2';
import { hashPassword } from '@/lib/auth';
export async function POST(req: NextRequest) {
try {
const { token, password } = await req.json();
if (!token || !password) {
return NextResponse.json({ error: 'Token och nytt lösenord krävs' }, { status: 400 });
}
if (password.length < 6) {
return NextResponse.json({ error: 'Lösenordet måste vara minst 6 tecken' }, { status: 400 });
}
// Find user by reset token
const [users] = await pool.query<RowDataPacket[]>(
'SELECT id FROM users WHERE verification_token = ?',
[token]
);
if (users.length === 0) {
return NextResponse.json({ error: 'Ogiltig eller utgången återställningslänk' }, { status: 400 });
}
const user = users[0];
const passwordHash = await hashPassword(password);
// Update password and clear reset token
const [result] = await pool.query<ResultSetHeader>(
'UPDATE users SET password_hash = ?, verification_token = NULL WHERE id = ?',
[passwordHash, user.id]
);
if (result.affectedRows === 0) {
throw new Error('Kunde inte uppdatera lösenordet');
}
return NextResponse.json({
success: true,
message: 'Ditt lösenord har ändrats framgångsrikt.'
});
} catch (error) {
console.error('Reset password error:', error);
return NextResponse.json({ error: 'Internt serverfel' }, { status: 500 });
}
}

View File

@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { RowDataPacket, ResultSetHeader } from 'mysql2';
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const token = searchParams.get('token');
if (!token) {
return NextResponse.redirect(new URL('/login?error=InvalidToken', req.url));
}
// Find user with this token
const [users] = await pool.query<RowDataPacket[]>(
'SELECT id FROM users WHERE verification_token = ?',
[token]
);
if (users.length === 0) {
return NextResponse.redirect(new URL('/login?error=InvalidToken', req.url));
}
const userId = users[0].id;
// Update user as verified and clear token
await pool.query<ResultSetHeader>(
'UPDATE users SET email_verified = TRUE, verification_token = NULL WHERE id = ?',
[userId]
);
return NextResponse.redirect(new URL('/login?verified=true', req.url));
} catch (error) {
console.error('Verification error:', error);
return NextResponse.redirect(new URL('/login?error=ServerError', req.url));
}
}

View File

@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import pool from '@/lib/db';
import { RowDataPacket } from 'mysql2';
export async function GET() {
try {
const [categories] = await pool.query<RowDataPacket[]>(
'SELECT * FROM categories ORDER BY name ASC'
);
return NextResponse.json(categories);
} catch (error) {
console.error('Get categories error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { sendContactEmail } from '@/lib/email';
export async function POST(req: NextRequest) {
try {
const { name, email, subject, message } = await req.json();
// Validation
if (!name || !email || !subject || !message) {
return NextResponse.json(
{ error: 'Alla fält är obligatoriska' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Ogiltig e-postadress' },
{ status: 400 }
);
}
// Send email
const success = await sendContactEmail(name, email, subject, message);
if (!success) {
return NextResponse.json(
{ error: 'Kunde inte skicka meddelandet. Försök igen senare.' },
{ status: 500 }
);
}
return NextResponse.json(
{ message: 'Meddelandet har skickats!' },
{ status: 200 }
);
} catch (error) {
console.error('Contact form error:', error);
return NextResponse.json(
{ error: 'Ett oväntat fel uppstod' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,277 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { verifyToken, TokenPayload } from '@/lib/auth';
import { RowDataPacket, ResultSetHeader } from 'mysql2';
import { sendEmail } from '@/lib/email';
function getUserFromRequest(request: NextRequest): TokenPayload | null {
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) return null;
const token = authHeader.substring(7);
try {
return verifyToken(token);
} catch {
return null;
}
}
interface RouteParams {
params: Promise<{ id: string }>;
}
// GET /api/conversations/[id] - Get conversation with messages
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const user = getUserFromRequest(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const [convRows] = await pool.query<RowDataPacket[]>(
`SELECT c.*, u.full_name as user_name, u.email as user_email, p.name as product_name
FROM conversations c
JOIN users u ON c.user_id = u.id
LEFT JOIN products p ON c.product_id = p.id
WHERE c.id = ?`,
[id]
);
if (convRows.length === 0) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
}
const conversation = convRows[0];
if (user.role !== 'admin' && conversation.user_id !== user.userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const [messages] = await pool.query<RowDataPacket[]>(
`SELECT m.*, u.full_name as sender_name
FROM messages m
JOIN users u ON m.sender_id = u.id
WHERE m.conversation_id = ?
ORDER BY m.created_at ASC`,
[id]
);
const readRole = user.role === 'admin' ? 'customer' : 'admin';
await pool.query(
`UPDATE messages SET is_read = TRUE
WHERE conversation_id = ? AND sender_role = ? AND is_read = FALSE`,
[id, readRole]
);
return NextResponse.json({ conversation, messages });
} catch (error) {
console.error('Error fetching conversation:', error);
return NextResponse.json({ error: 'Server error' }, { status: 500 });
}
}
// POST /api/conversations/[id] - Add message to conversation
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const user = getUserFromRequest(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const { content } = body;
if (!content) {
return NextResponse.json({ error: 'Message content required' }, { status: 400 });
}
const [convRows] = await pool.query<RowDataPacket[]>(
`SELECT c.*, u.full_name as user_name, u.email as user_email
FROM conversations c
JOIN users u ON c.user_id = u.id
WHERE c.id = ?`,
[id]
);
if (convRows.length === 0) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
}
const conversation = convRows[0];
if (user.role !== 'admin' && conversation.user_id !== user.userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const senderRole = user.role === 'admin' ? 'admin' : 'customer';
await pool.query<ResultSetHeader>(
`INSERT INTO messages (conversation_id, sender_id, sender_role, content) VALUES (?, ?, ?, ?)`,
[id, user.userId, senderRole, content]
);
await pool.query(
`UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[id]
);
// Send email notification based on recipient's online status
if (user.role === 'admin') {
// Admin replying to customer
const [sessions] = await pool.query<RowDataPacket[]>(
`SELECT COUNT(*) as count FROM user_sessions
WHERE user_id = ? AND expires_at > NOW()`,
[conversation.user_id]
);
const isCustomerOnline = sessions[0].count > 0;
if (!isCustomerOnline) {
await sendEmail(
conversation.user_email,
`Nytt svar: ${conversation.subject}`,
`
<h2>Du har fått ett svar</h2>
<p><strong>Ämne:</strong> ${conversation.subject}</p>
<p><strong>Meddelande:</strong></p>
<blockquote style="border-left: 3px solid #ccc; padding-left: 10px;">${content}</blockquote>
<p><a href="${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/messages/${id}">Se konversation</a></p>
`
);
}
} else {
// Customer replying to admins
const [admins] = await pool.query<RowDataPacket[]>(
`SELECT id, email FROM users WHERE role = 'admin'`
);
for (const admin of admins) {
const [sessions] = await pool.query<RowDataPacket[]>(
`SELECT COUNT(*) as count FROM user_sessions
WHERE user_id = ? AND expires_at > NOW()`,
[admin.id]
);
const isAdminOnline = sessions[0].count > 0;
if (!isAdminOnline) {
await sendEmail(
admin.email,
`Nytt svar: ${conversation.subject}`,
`
<h2>Kundsvar</h2>
<p><strong>Från:</strong> ${conversation.user_name}</p>
<p><strong>Meddelande:</strong></p>
<blockquote style="border-left: 3px solid #ccc; padding-left: 10px;">${content}</blockquote>
<p><a href="${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/admin/messages/${id}">Svara här</a></p>
`
);
}
}
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error sending message:', error);
return NextResponse.json({ error: 'Server error' }, { status: 500 });
}
}
// DELETE /api/conversations/[id] - Delete conversation
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const user = getUserFromRequest(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const [convRows] = await pool.query<RowDataPacket[]>(
`SELECT * FROM conversations WHERE id = ?`,
[id]
);
if (convRows.length === 0) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
}
const conversation = convRows[0];
// Only admin can delete completely, customers can only delete their own
if (user.role !== 'admin' && conversation.user_id !== user.userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Delete the conversation (cascades to messages)
await pool.query(`DELETE FROM conversations WHERE id = ?`, [id]);
return NextResponse.json({ success: true, message: 'Conversation deleted' });
} catch (error) {
console.error('Error deleting conversation:', error);
return NextResponse.json({ error: 'Server error' }, { status: 500 });
}
}
// PATCH /api/conversations/[id] - Resolve conversation (admin only)
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const user = getUserFromRequest(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (user.role !== 'admin') {
return NextResponse.json({ error: 'Admin only' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const { action } = body;
if (action === 'resolve') {
// Get conversation info for email
const [convRows] = await pool.query<RowDataPacket[]>(
`SELECT c.*, u.email as user_email, u.full_name as user_name
FROM conversations c
JOIN users u ON c.user_id = u.id
WHERE c.id = ?`,
[id]
);
if (convRows.length === 0) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
}
const conversation = convRows[0];
// Update status to closed
await pool.query(
`UPDATE conversations SET status = 'closed' WHERE id = ?`,
[id]
);
// Send resolution email to customer
await sendEmail(
conversation.user_email,
`Ärende löst: ${conversation.subject}`,
`
<h2>Ditt ärende har markerats som löst</h2>
<p>Hej ${conversation.user_name || 'kund'},</p>
<p>Vi har markerat ditt ärende "<strong>${conversation.subject}</strong>" som löst.</p>
<p>Om du fortfarande har frågor eller känner att ärendet inte är helt löst, tveka inte att kontakta oss igen.</p>
<p>Med vänliga hälsningar,<br/>Nordic Storium Support</p>
`
);
return NextResponse.json({ success: true, message: 'Conversation resolved' });
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
} catch (error) {
console.error('Error resolving conversation:', error);
return NextResponse.json({ error: 'Server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { verifyToken, TokenPayload } from '@/lib/auth';
import { RowDataPacket, ResultSetHeader } from 'mysql2';
import { sendEmail } from '@/lib/email';
function getUserFromRequest(request: NextRequest): TokenPayload | null {
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) return null;
const token = authHeader.substring(7);
try {
return verifyToken(token);
} catch {
return null;
}
}
// GET /api/conversations - List user's conversations
export async function GET(request: NextRequest) {
try {
const user = getUserFromRequest(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
let sql: string;
let params: (string | number)[] = [];
if (user.role === 'admin') {
sql = `
SELECT
c.id, c.subject, c.status, c.created_at, c.updated_at,
u.id as user_id, u.full_name as user_name, u.email as user_email,
p.id as product_id, p.name as product_name,
(SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id AND m.is_read = FALSE AND m.sender_role = 'customer') as unread_count,
(SELECT content FROM messages m WHERE m.conversation_id = c.id ORDER BY m.created_at DESC LIMIT 1) as last_message
FROM conversations c
JOIN users u ON c.user_id = u.id
LEFT JOIN products p ON c.product_id = p.id
ORDER BY c.updated_at DESC
`;
} else {
sql = `
SELECT
c.id, c.subject, c.status, c.created_at, c.updated_at,
p.id as product_id, p.name as product_name,
(SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id AND m.is_read = FALSE AND m.sender_role = 'admin') as unread_count,
(SELECT content FROM messages m WHERE m.conversation_id = c.id ORDER BY m.created_at DESC LIMIT 1) as last_message
FROM conversations c
LEFT JOIN products p ON c.product_id = p.id
WHERE c.user_id = ?
ORDER BY c.updated_at DESC
`;
params = [user.userId];
}
const [rows] = await pool.query<RowDataPacket[]>(sql, params);
return NextResponse.json(rows);
} catch (error) {
console.error('Error fetching conversations:', error);
return NextResponse.json({ error: 'Server error' }, { status: 500 });
}
}
// POST /api/conversations - Create new conversation
export async function POST(request: NextRequest) {
try {
const user = getUserFromRequest(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { product_id, subject, message } = body;
if (!subject || !message) {
return NextResponse.json({ error: 'Subject and message are required' }, { status: 400 });
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [convResult] = await connection.query<ResultSetHeader>(
`INSERT INTO conversations (user_id, product_id, subject) VALUES (?, ?, ?)`,
[user.userId, product_id || null, subject]
);
const conversationId = convResult.insertId;
await connection.query<ResultSetHeader>(
`INSERT INTO messages (conversation_id, sender_id, sender_role, content) VALUES (?, ?, 'customer', ?)`,
[conversationId, user.userId, message]
);
await connection.commit();
// Notify admins via email if they are offline
const [admins] = await pool.query<RowDataPacket[]>(
`SELECT id, email FROM users WHERE role = 'admin'`
);
for (const admin of admins) {
// Check if admin is online (has active session)
const [sessions] = await pool.query<RowDataPacket[]>(
`SELECT COUNT(*) as count FROM user_sessions
WHERE user_id = ? AND expires_at > NOW()`,
[admin.id]
);
const isOnline = sessions[0].count > 0;
if (!isOnline) {
await sendEmail(
admin.email,
`Nytt meddelande: ${subject}`,
`
<h2>Nytt kundmeddelande</h2>
<p><strong>Från:</strong> ${user.email}</p>
<p><strong>Ämne:</strong> ${subject}</p>
<p><strong>Meddelande:</strong></p>
<blockquote style="border-left: 3px solid #ccc; padding-left: 10px; margin: 10px 0;">${message}</blockquote>
<p><a href="${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/admin/messages/${conversationId}">Svara här</a></p>
`
);
}
}
return NextResponse.json({
success: true,
conversationId,
message: 'Meddelande skickat!'
}, { status: 201 });
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
} catch (error) {
console.error('Error creating conversation:', error);
return NextResponse.json({ error: 'Server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { verifyToken, TokenPayload } from '@/lib/auth';
import { RowDataPacket } from 'mysql2';
function getUserFromRequest(request: NextRequest): TokenPayload | null {
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) return null;
const token = authHeader.substring(7);
try {
return verifyToken(token);
} catch {
return null;
}
}
// GET /api/conversations/unread - Get unread message count
export async function GET(request: NextRequest) {
try {
const user = getUserFromRequest(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
let sql: string;
let params: (string | number)[] = [];
if (user.role === 'admin') {
sql = `
SELECT COUNT(*) as count
FROM messages m
JOIN conversations c ON m.conversation_id = c.id
WHERE m.is_read = FALSE AND m.sender_role = 'customer'
`;
} else {
sql = `
SELECT COUNT(*) as count
FROM messages m
JOIN conversations c ON m.conversation_id = c.id
WHERE c.user_id = ? AND m.is_read = FALSE AND m.sender_role = 'admin'
`;
params = [user.userId];
}
const [rows] = await pool.query<RowDataPacket[]>(sql, params);
return NextResponse.json({ count: rows[0].count });
} catch (error) {
console.error('Error fetching unread count:', error);
return NextResponse.json({ error: 'Server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,85 @@
import { NextResponse } from 'next/server';
import pool from '@/lib/db';
import { RowDataPacket } from 'mysql2';
export async function GET() {
try {
const [categories] = await pool.query<RowDataPacket[]>('SELECT * FROM categories ORDER BY name ASC');
const [brands] = await pool.query<RowDataPacket[]>(`
SELECT brand, COUNT(*) as count
FROM products
WHERE brand IS NOT NULL AND brand != ''
GROUP BY brand
ORDER BY count DESC
`);
const [materials] = await pool.query<RowDataPacket[]>(`
SELECT material, COUNT(*) as count
FROM products
WHERE material IS NOT NULL AND material != ''
GROUP BY material
ORDER BY count DESC
`);
const [conditions] = await pool.query<RowDataPacket[]>(`
SELECT product_condition, COUNT(*) as count
FROM products
GROUP BY product_condition
ORDER BY count DESC
`);
const [colors] = await pool.query<RowDataPacket[]>(`
SELECT color, COUNT(*) as count
FROM products
WHERE color IS NOT NULL AND color != ''
GROUP BY color
ORDER BY count DESC
`);
const [priceRange] = await pool.query<RowDataPacket[]>(`
SELECT MIN(price) as min, MAX(price) as max FROM products
`);
// Build Category Tree
// Build Category Tree
interface CategoryNode extends RowDataPacket {
id: number;
parent_id: number | null;
name: string;
children: CategoryNode[];
}
const categoryMap = new Map<number, CategoryNode>();
const categoryTree: CategoryNode[] = [];
// 1. Initialize map
categories.forEach((cat) => {
// Need cast because RowDataPacket doesn't strictly adhere to our interface without it
const node = { ...cat, children: [] } as CategoryNode;
categoryMap.set(node.id, node);
});
// 2. Build tree
categoryMap.forEach((node) => {
if (node.parent_id && categoryMap.has(node.parent_id)) {
categoryMap.get(node.parent_id)?.children.push(node);
} else {
categoryTree.push(node);
}
});
return NextResponse.json({
categories: categoryTree,
brands,
materials,
conditions,
colors,
priceRange: priceRange[0]
});
} catch (error) {
console.error('Filters API Error:', error);
return NextResponse.json({ error: 'Failed to fetch filters' }, { status: 500 });
}
}

View File

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { RowDataPacket } from 'mysql2';
interface Params {
params: Promise<{ id: string }>;
}
export async function GET(req: NextRequest, { params }: Params) {
try {
const { id } = await params;
const productId = parseInt(id);
if (isNaN(productId)) {
return NextResponse.json({ error: 'Invalid product ID' }, { status: 400 });
}
// Fetch product with joined data
const [rows] = await pool.query<RowDataPacket[]>(`
SELECT p.*,
GROUP_CONCAT(DISTINCT pi.image_url ORDER BY pi.is_primary DESC, pi.display_order ASC) as product_images,
GROUP_CONCAT(DISTINCT c.name) as category_names
FROM products p
LEFT JOIN product_images pi ON p.id = pi.product_id
LEFT JOIN product_categories pc ON p.id = pc.product_id
LEFT JOIN categories c ON pc.category_id = c.id
WHERE p.id = ?
GROUP BY p.id
`, [productId]);
if (rows.length === 0) {
return NextResponse.json({ error: 'Product not found' }, { status: 404 });
}
const product = rows[0];
// Format images - product_images table is the source of truth for uploaded images
const uploadedImages = product.product_images ? product.product_images.split(',') : [];
const allImages = uploadedImages;
const formattedProduct = {
...product,
images: allImages,
category_names: product.category_names ? product.category_names.split(',') : [],
price: Number(product.price),
original_price: product.original_price ? Number(product.original_price) : null
};
return NextResponse.json(formattedProduct);
} catch (error) {
console.error('Fetch product detail error:', error);
return NextResponse.json({ error: 'Failed to fetch product' }, { status: 500 });
}
}

View File

@ -0,0 +1,163 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { RowDataPacket } from 'mysql2';
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
// Pagination
const page = Math.max(1, parseInt(searchParams.get('page') || '1'));
const limit = Math.max(1, Math.min(50, parseInt(searchParams.get('limit') || '12')));
const offset = (page - 1) * limit;
// Filters
const q = searchParams.get('q');
const sort = searchParams.get('sort') || 'newest';
const minPrice = searchParams.get('minPrice');
const maxPrice = searchParams.get('maxPrice');
const brands = searchParams.get('brands'); // comma separated
const conditions = searchParams.get('conditions'); // comma separated
const materials = searchParams.get('materials'); // comma separated
const colors = searchParams.get('colors'); // comma separated
const categoryId = searchParams.get('category_id');
const inStock = searchParams.get('in_stock');
// Build Query - primary_image comes from product_images table first
let sql = `
SELECT SQL_CALC_FOUND_ROWS p.*,
(SELECT pi.image_url FROM product_images pi WHERE pi.product_id = p.id ORDER BY pi.is_primary DESC, pi.display_order ASC LIMIT 1) as primary_image,
GROUP_CONCAT(DISTINCT c.name) as category_names
FROM products p
LEFT JOIN product_categories pc ON p.id = pc.product_id
LEFT JOIN categories c ON pc.category_id = c.id
`;
const whereConditions: string[] = [];
const params: (string | number)[] = [];
// Search (Name or Description or Brand or Category Name)
if (q) {
whereConditions.push(`(
p.name LIKE ? OR
p.description LIKE ? OR
p.brand LIKE ? OR
c.name LIKE ?
)`);
const searchTerm = `%${q}%`;
params.push(searchTerm, searchTerm, searchTerm, searchTerm);
}
// Price Range
if (minPrice) {
whereConditions.push('p.price >= ?');
params.push(minPrice);
}
if (maxPrice) {
whereConditions.push('p.price <= ?');
params.push(maxPrice);
}
// Brands
if (brands) {
const brandList = brands.split(',');
whereConditions.push(`p.brand IN (${brandList.map(() => '?').join(',')})`);
params.push(...brandList);
}
// Conditions
if (conditions) {
const conditionList = conditions.split(',');
whereConditions.push(`p.product_condition IN (${conditionList.map(() => '?').join(',')})`);
params.push(...conditionList);
}
// Materials
if (materials) {
const materialList = materials.split(',');
whereConditions.push(`p.material IN (${materialList.map(() => '?').join(',')})`);
params.push(...materialList);
}
// Colors
if (colors) {
const colorList = colors.split(',');
whereConditions.push(`p.color IN (${colorList.map(() => '?').join(',')})`);
params.push(...colorList);
}
// Category (Include subcategories 1 level deep)
if (categoryId) {
// This is simplified. For full recursion, we'd need more logic.
// Check if product belongs to this category OR any of its children
whereConditions.push(`p.id IN (
SELECT pc2.product_id
FROM product_categories pc2
JOIN categories c2 ON pc2.category_id = c2.id
WHERE c2.id = ? OR c2.parent_id = ?
)`);
params.push(categoryId, categoryId);
}
// Stock Status
if (inStock === 'true') {
whereConditions.push("p.stock_status = 'in_stock'");
}
if (whereConditions.length > 0) {
sql += ' WHERE ' + whereConditions.join(' AND ');
}
// Group By
sql += ' GROUP BY p.id';
// Sorting
switch (sort) {
case 'price_asc':
sql += ' ORDER BY p.price ASC';
break;
case 'price_desc':
sql += ' ORDER BY p.price DESC';
break;
case 'name_asc':
sql += ' ORDER BY p.name ASC';
break;
case 'name_desc':
sql += ' ORDER BY p.name DESC';
break;
case 'newest':
sql += ' ORDER BY p.created_at DESC';
break;
case 'random':
sql += ' ORDER BY RAND()';
break;
default:
sql += ' ORDER BY p.created_at DESC';
break;
}
// Pagination
sql += ' LIMIT ? OFFSET ?';
params.push(limit, offset);
const [rows] = await pool.query<RowDataPacket[]>(sql, params);
// Get total count
const [countResult] = await pool.query<RowDataPacket[]>('SELECT FOUND_ROWS() as total');
const total = countResult[0].total;
return NextResponse.json({
products: rows,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit)
}
});
} catch (error) {
console.error('Search API Error:', error);
return NextResponse.json({ error: 'Failed to fetch products' }, { status: 500 });
}
}

View File

@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { RowDataPacket } from 'mysql2';
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const q = searchParams.get('q');
if (!q || q.length < 2) {
return NextResponse.json([]);
}
const sql = `
SELECT p.id, p.name, p.price, p.brand,
(SELECT pi.image_url FROM product_images pi WHERE pi.product_id = p.id ORDER BY pi.is_primary DESC, pi.display_order ASC LIMIT 1) as image
FROM products p
LEFT JOIN product_categories pc ON p.id = pc.product_id
LEFT JOIN categories c ON pc.category_id = c.id
WHERE p.name LIKE ? OR p.brand LIKE ? OR c.name LIKE ?
GROUP BY p.id
LIMIT 5
`;
const searchTerm = `%${q}%`;
const [rows] = await pool.query<RowDataPacket[]>(sql, [searchTerm, searchTerm, searchTerm]);
return NextResponse.json(rows);
} catch (error) {
console.error('Autocomplete Error:', error);
return NextResponse.json({ error: 'Failed to search' }, { status: 500 });
}
}

View File

@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from 'next/server';
import { sendSellFurnitureEmail } from '@/lib/email';
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const phone = formData.get('phone') as string;
const address = formData.get('address') as string;
const message = formData.get('message') as string;
// Validation
if (!name || !email || !phone || !address || !message) {
return NextResponse.json(
{ error: 'Alla fält är obligatoriska' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Ogiltig e-postadress' },
{ status: 400 }
);
}
// Get uploaded images
const images: { filename: string; content: Buffer; contentType: string }[] = [];
const imageFiles = formData.getAll('images');
if (imageFiles.length > 4) {
return NextResponse.json(
{ error: 'Max 4 bilder tillåtna' },
{ status: 400 }
);
}
for (const file of imageFiles) {
if (file instanceof File) {
// Validate file type
if (!file.type.startsWith('image/')) {
return NextResponse.json(
{ error: 'Endast bildfiler tillåtna' },
{ status: 400 }
);
}
// Validate file size (max 5MB per image)
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json(
{ error: 'Bilderna får max vara 5MB vardera' },
{ status: 400 }
);
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
images.push({
filename: file.name,
content: buffer,
contentType: file.type
});
}
}
// Send email with attachments
const success = await sendSellFurnitureEmail(
name,
email,
phone,
address,
message,
images
);
if (!success) {
return NextResponse.json(
{ error: 'Kunde inte skicka meddelandet. Försök igen senare.' },
{ status: 500 }
);
}
return NextResponse.json(
{ message: 'Tack! Vi återkommer så snart vi kan.' },
{ status: 200 }
);
} catch (error) {
console.error('Sell furniture form error:', error);
return NextResponse.json(
{ error: 'Ett oväntat fel uppstod' },
{ status: 500 }
);
}
}

93
src/app/forgot/page.tsx Normal file
View File

@ -0,0 +1,93 @@
"use client";
import { useState } from 'react';
import Link from 'next/link';
import '@/styles/Auth.css';
export default function ForgotPasswordPage() {
const [emailOrUsername, setEmailOrUsername] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccessMessage('');
setLoading(true);
try {
const res = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emailOrUsername }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Något gick fel');
setSuccessMessage(data.message);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ett oväntat fel uppstod');
} finally {
setLoading(false);
}
};
return (
<div className="auth-page-container">
<div className="profile-card card-sm">
<h2 className="auth-title">HAR DU GLÖMT DITT LÖSENORD?</h2>
<p className="auth-subtitle">
Skriv ditt email eller användarnamn när du skapade kontot att vi kan skicka ett nytt lösenord.
</p>
{successMessage ? (
<div className="text-center">
<div className="auth-success">
{successMessage}
</div>
<Link href="/login" className="auth-button">
Tillbaka till inloggning
</Link>
</div>
) : (
<form className="auth-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">E-post / Användarnamn</label>
<input
id="email"
type="text"
required
className="form-input"
value={emailOrUsername}
onChange={(e) => setEmailOrUsername(e.target.value)}
/>
</div>
{error && (
<div className="auth-error">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="auth-button"
>
{loading ? 'Skickar...' : 'Återställ Lösenord'}
</button>
<div className="text-center mt-4">
<Link href="/login" className="auth-link text-sm">
tillbaka
</Link>
</div>
</form>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,125 @@
import Link from 'next/link';
export default function Integritetspolicy() {
return (
<div className="container mx-auto px-4 py-12">
<div className="max-w-4xl mx-auto p-8 rounded-2xl border border-[var(--border-color)] bg-[var(--card-bg)] shadow-lg">
<header className="text-center mb-12 pb-8 border-b border-[var(--border-color)]">
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-[var(--text-color)] font-['Montserrat']">Integritetspolicy</h1>
<p className="text-[var(--text-secondary)]">Gäller från: {new Date().toLocaleDateString('sv-SE')}</p>
</header>
<div className="space-y-10 text-[var(--text-color)]">
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">1. Personuppgiftsansvarig</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Nordic Storium är personuppgiftsansvarig för behandlingen av dina personuppgifter. Vi värnar om din integritet och strävar efter att alltid skydda dina personuppgifter bästa möjliga sätt i enlighet med dataskyddsförordningen (GDPR).
</p>
<div className="p-6 bg-[var(--background)] rounded-lg border border-[var(--border-color)] mb-4">
<p className="mb-1 font-semibold text-[var(--text-heading)]">Nordic Storium</p>
<p className="mb-0 text-[var(--text-secondary)]">Adress: Skarprättarvägen 30, 176 77 Järfälla</p>
<p className="mt-2"><a href="mailto:info@nordicstorium.se" className="text-blue-600 hover:underline">info@nordicstorium.se</a></p>
</div>
</section>
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">2. Vilka personuppgifter samlar vi in?</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Vi samlar in uppgifter som du frivilligt lämnar till oss, exempelvis när du kontaktar oss, lägger en beställning eller besöker vår webbplats.
</p>
<ul className="list-disc pl-5 space-y-2 text-[var(--text-secondary)]">
<li><strong>Kontaktuppgifter:</strong> Namn, e-postadress, telefonnummer, adress.</li>
<li><strong>Orderinformation:</strong> Information om beställda varor, leveransadress och betalningshistorik.</li>
<li><strong>Kommunikation:</strong> E-post, chattloggar och anteckningar från kundserviceärenden.</li>
<li><strong>Teknisk data:</strong> IP-adress, webbläsartyp och information om hur du interagerar med vår webbplats (via cookies).</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">3. Varför behandlar vi dina uppgifter?</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Vi behandlar dina uppgifter för följande ändamål och med stöd av följande rättsliga grunder:
</p>
<div className="space-y-4">
<div className="p-4 bg-[var(--background)] rounded-lg border border-[var(--border-color)]">
<h3 className="font-bold text-[var(--text-heading)] mb-2">Hantering av beställningar</h3>
<p className="text-sm text-[var(--text-secondary)] mb-1">För att kunna leverera varor, hantera betalningar och ge support.</p>
<p className="text-xs font-semibold text-blue-600 uppercase">Rättslig grund: Fullgörande av avtal</p>
</div>
<div className="p-4 bg-[var(--background)] rounded-lg border border-[var(--border-color)]">
<h3 className="font-bold text-[var(--text-heading)] mb-2">Kundservice och support</h3>
<p className="text-sm text-[var(--text-secondary)] mb-1">För att kunna svara frågor och hantera reklamationer.</p>
<p className="text-xs font-semibold text-blue-600 uppercase">Rättslig grund: Berättigat intresse & Avtal</p>
</div>
<div className="p-4 bg-[var(--background)] rounded-lg border border-[var(--border-color)]">
<h3 className="font-bold text-[var(--text-heading)] mb-2">Bokföring och lagkrav</h3>
<p className="text-sm text-[var(--text-secondary)] mb-1">Vi måste spara viss information enligt bokföringslagen.</p>
<p className="text-xs font-semibold text-blue-600 uppercase">Rättslig grund: Rättslig förpliktelse</p>
</div>
</div>
</section>
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">4. Vilka delar vi dina uppgifter med?</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Vi säljer aldrig dina uppgifter. Vi kan dock komma att dela dem med betrodda partners för att kunna utföra våra tjänster:
</p>
<ul className="list-disc pl-5 space-y-2 text-[var(--text-secondary)]">
<li><strong>Fraktbolag:</strong> För att kunna leverera din beställning (t.ex. Postnord).</li>
<li><strong>Betaltjänstleverantörer:</strong> För att säkerställa säker betalning (t.ex. via bank eller Swish).</li>
<li><strong>IT-leverantörer:</strong> För drift, support och underhåll av våra IT-system.</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">5. Hur länge sparar vi dina uppgifter?</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Vi sparar dina uppgifter länge det är nödvändigt för respektive ändamål:
</p>
<ul className="list-disc pl-5 space-y-2 text-[var(--text-secondary)]">
<li><strong>Beställningsinformation:</strong> Sparas i 7 år enligt bokföringslagen.</li>
<li><strong>Kundserviceärenden:</strong> Sparas länge ärendet pågår och en tid därefter för uppföljning (normalt 12 månader).</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">6. Dina rättigheter</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Enligt GDPR har du flera rättigheter rörande dina personuppgifter:
</p>
<ul className="list-disc pl-5 space-y-2 text-[var(--text-secondary)]">
<li><strong>Rätt till tillgång:</strong> Du har rätt att ett registerutdrag över vilka uppgifter vi har om dig.</li>
<li><strong>Rätt till rättelse:</strong> Du kan begära att vi rättar felaktiga uppgifter.</li>
<li><strong>Rätt till radering:</strong> Du kan begära att bli raderad (&quot;rätten att bli bortglömd&quot;), förutom lagkrav kräver att vi sparar uppgifterna.</li>
<li><strong>Rätt till dataportabilitet:</strong> Rätt att ut dina uppgifter i ett strukturerat format.</li>
</ul>
<p className="mt-4 leading-relaxed text-[var(--text-secondary)]">
Kontakta oss <a href="mailto:info@nordicstorium.se" className="text-blue-600 hover:underline">info@nordicstorium.se</a> om du vill utöva dina rättigheter.
</p>
</section>
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">7. Cookies</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Vi använder cookies för att webbplatsen ska fungera korrekt och för att ge dig en bättre upplevelse.
Du kan själv styra användningen av cookies i din webbläsares inställningar.
</p>
</section>
</div>
<footer className="mt-12 pt-8 border-t border-[var(--border-color)] text-center">
<Link href="/" className="inline-block px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors duration-300">
Tillbaka till startsidan
</Link>
</footer>
</div>
</div >
);
}

View File

@ -0,0 +1,268 @@
"use client";
import { useState } from 'react';
import '@/styles/Auth.css';
export default function KontaktaOss() {
const [formData, setFormData] = useState({
name: '',
email: '',
subject: '',
message: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<'success' | 'error' | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value
}));
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setSubmitStatus(null);
setErrorMessage('');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = await res.json();
if (res.ok) {
setSubmitStatus('success');
setFormData({ name: '', email: '', subject: '', message: '' });
} else {
setSubmitStatus('error');
setErrorMessage(data.error || 'Något gick fel. Försök igen.');
}
} catch (err) {
console.error('Contact form error:', err);
setSubmitStatus('error');
setErrorMessage('Kunde inte skicka meddelandet. Försök igen senare.');
} finally {
setIsSubmitting(false);
setTimeout(() => {
setSubmitStatus(null);
setErrorMessage('');
}, 5000);
}
};
return (
<div className="auth-page-container">
<div className="card-xl" style={{ width: '100%', maxWidth: '1600px' }}>
<div className="flex flex-col items-center mb-12 border-b border-gray-100 dark:border-white/10 pb-8">
<h2 className="auth-title" style={{ marginBottom: '0.5rem' }}>Kontakta Oss</h2>
<p style={{ fontSize: '0.9rem', color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '1px' }}>
Skicka oss ett meddelande återkopplar vi snart vi kan
</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '2rem', width: '100%' }} className="contact-grid">
{/* Card 1: Contact Form */}
<div className="profile-card" style={{ width: '100%', boxSizing: 'border-box' }}>
<h3 className="auth-subtitle" style={{ marginBottom: '2rem' }}>Skicka Meddelande</h3>
<form onSubmit={handleSubmit}>
<div className="form-group" style={{ position: 'relative', marginBottom: '1.5rem' }}>
<input
type="text"
name="subject"
placeholder="Ämne"
className="form-input"
value={formData.subject}
onChange={handleChange}
required
/>
</div>
<div className="form-group" style={{ position: 'relative', marginBottom: '1.5rem' }}>
<input
type="text"
name="name"
placeholder="Namn"
className="form-input"
value={formData.name}
onChange={handleChange}
required
/>
</div>
<div className="form-group" style={{ position: 'relative', marginBottom: '1.5rem' }}>
<input
type="email"
name="email"
placeholder="Email"
className="form-input"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group" style={{ position: 'relative', marginBottom: '1.5rem' }}>
<textarea
name="message"
placeholder="Meddelande"
className="form-input"
value={formData.message}
onChange={handleChange}
required
rows={6}
style={{ resize: 'vertical', minHeight: '180px', paddingTop: '1.2rem' }}
/>
</div>
<button type="submit" className="auth-button primary" disabled={isSubmitting} style={{ width: '100%' }}>
{isSubmitting ? 'Skickar...' : 'Skicka Meddelande'}
</button>
{submitStatus === 'success' && (
<div className="auth-success" style={{ marginTop: '1.5rem' }}>
Tack för ditt meddelande! Vi återkommer snart vi kan.
</div>
)}
{submitStatus === 'error' && (
<div className="auth-error" style={{ marginTop: '1.5rem' }}>
{errorMessage}
</div>
)}
</form>
</div>
{/* Card 2: Map & Contact Info */}
<div className="profile-card" style={{ padding: '0', overflow: 'hidden', width: '100%', boxSizing: 'border-box' }}>
{/* Map */}
<div style={{ width: '100%', height: '350px', position: 'relative', borderBottom: '2px solid var(--border-color)' }}>
<iframe
title="Nordic Storium Location"
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d127.06584009758762!2d17.878522835224487!3d59.36544698393505!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x465f9fcd5640a071%3A0x14cb18bf7f9ea5c!2sH%C3%A4rjedalsgatan%2014%2C%20162%2066%20V%C3%A4llingby!5e0!3m2!1sen!2sse!4v1748976558911!5m2!1sen!2sse"
style={{ border: 0, width: '100%', height: '100%' }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
{/* Contact Info */}
<div style={{ padding: 'clamp(1.5rem, 5vw, 3rem)' }}>
<h3 className="auth-subtitle" style={{ marginBottom: '2rem' }}>Kontaktinformation</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div className="contact-info-item">
<p style={{
fontSize: '0.7rem',
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '2px',
fontWeight: '900',
marginBottom: '0.75rem'
}}>
📍 Adress
</p>
<p style={{ fontSize: '0.95rem', lineHeight: '1.6', color: 'var(--text-color)' }}>
Skarprättarvägen 30<br />
176 77 Järfälla
</p>
</div>
<div className="contact-info-item" style={{ paddingTop: '1.5rem', borderTop: '1px solid var(--border-color)' }}>
<p style={{
fontSize: '0.7rem',
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '2px',
fontWeight: '900',
marginBottom: '0.75rem'
}}>
🕒 Öppettider
</p>
<p style={{ fontSize: '0.95rem', lineHeight: '1.6', color: 'var(--text-color)' }}>
Måndag - Fredag<br />
13:00 - 22:00
</p>
</div>
<div className="contact-info-item" style={{ paddingTop: '1.5rem', borderTop: '1px solid var(--border-color)' }}>
<p style={{
fontSize: '0.7rem',
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '2px',
fontWeight: '900',
marginBottom: '0.75rem'
}}>
📧 Email
</p>
<p style={{ fontSize: '0.95rem', color: 'var(--text-color)' }}>
<a
href="mailto:vallingby.korakademin@outlook.com"
style={{
color: 'var(--text-color)',
textDecoration: 'none',
transition: 'color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--accent)'}
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-color)'}
>
vallingby.korakademin@outlook.com
</a>
</p>
</div>
<div className="contact-info-item" style={{ paddingTop: '1.5rem', borderTop: '1px solid var(--border-color)' }}>
<p style={{
fontSize: '0.7rem',
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '2px',
fontWeight: '900',
marginBottom: '0.75rem'
}}>
📱 Telefon
</p>
<p style={{ fontSize: '0.95rem', color: 'var(--text-color)' }}>
<a
href="tel:0700342324"
style={{
color: 'var(--text-color)',
textDecoration: 'none',
transition: 'color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--accent)'}
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-color)'}
>
070-034 23 24
</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<style jsx>{`
@media (min-width: 900px) {
.contact-grid {
grid-template-columns: 1fr 1fr !important;
}
}
@media (max-width: 899px) {
.contact-grid {
grid-template-columns: 1fr !important;
}
}
`}</style>
</div>
);
}

66
src/app/layout.tsx Normal file
View File

@ -0,0 +1,66 @@
import type * as next from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../styles/globals.css";
import { Providers } from "../components/Providers";
import Navbar from "../components/Navbar";
import Footer from "../components/Footer";
import CookieConsentModal from "../components/CookieConsentModal";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: next.Metadata = {
metadataBase: new URL("https://www.nordicstorium.se"),
title: "Nordic Storium Kvalitetsmöbler till konkurspriser",
description:
"Nordic Storium säljer kontorsmöbler, stolar och inredning från konkursbon och företag. Hög kvalitet till låga priser. Fri frakt nära Stockholm.",
keywords: [
"kontorsmöbler",
"begagnade möbler",
"konkursbon möbler",
"kontorsstolar",
"möbler Stockholm",
"billiga möbler",
"kvalitetsmöbler",
],
icons: {
icon: [
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
],
apple: "/apple-touch-icon.png",
},
manifest: "/site.webmanifest",
openGraph: {
title: "Nordic Storium Kvalitetsmöbler till konkurspriser",
description:
"Kvalitetsmöbler till oslagbara priser. Vi köper in möbler från konkursbon och företag.",
url: "https://www.nordicstorium.se",
siteName: "Nordic Storium",
images: ["/android-chrome-512x512.png"],
locale: "sv_SE",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Nordic Storium Kvalitetsmöbler till konkurspriser",
description:
"Köp kontorsmöbler, stolar och inredning från konkursbon. Hög kvalitet till låga priser.",
images: ["/android-chrome-512x512.png"],
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="sv" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<Providers>
<Navbar />
<main>{children}</main>
<Footer />
<CookieConsentModal />
</Providers>
</body>
</html>
);
}

337
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,337 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuth } from '@/context/AuthContext';
import { FcGoogle } from 'react-icons/fc';
import '@/styles/Auth.css';
export default function LoginPage() {
const router = useRouter();
const { login, isAuthenticated, loading: authLoading } = useAuth();
const [formData, setFormData] = useState({
emailOrUsername: '',
password: '',
rememberMe: false,
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [twoFactorRequired, setTwoFactorRequired] = useState(false);
const [twoFactorUserId, setTwoFactorUserId] = useState<number | null>(null);
const [twoFactorCode, setTwoFactorCode] = useState('');
const [successMessage, setSuccessMessage] = useState('');
useEffect(() => {
// Load remembered credentials ONCE on mount
const savedEmail = localStorage.getItem('remembered_email');
if (savedEmail) {
setFormData(prev => ({ ...prev, emailOrUsername: savedEmail, rememberMe: true }));
}
}, []);
const processingGoogleAuth = useRef(false);
useEffect(() => {
if (!authLoading && isAuthenticated) {
router.push('/me');
return;
}
if (processingGoogleAuth.current) return;
// Check for verification query params
const params = new URLSearchParams(window.location.search);
// Handle successful Google Redirect
const token = params.get('token');
const userData = params.get('user');
if (token && userData) {
processingGoogleAuth.current = true;
try {
login(token, JSON.parse(userData), true);
// Clear URL parameters to prevent re-processing on refresh/back
window.history.replaceState({}, document.title, window.location.pathname);
router.push('/me');
return;
} catch (e) {
console.error("Failed to parse Google user data", e);
processingGoogleAuth.current = false;
}
}
if (params.get('verified') === 'true') {
setSuccessMessage('Din e-postadress har blivit verifierad! Du kan nu logga in.');
} else if (params.get('error') === 'InvalidToken') {
setError('Verifieringslänken är ogiltig eller har gått ut.');
} else if (params.get('error') === 'GoogleAuthFailed') {
setError('Google-inloggning misslyckades. Försök igen.');
}
}, [isAuthenticated, authLoading, router, login]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccessMessage('');
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: formData.emailOrUsername,
password: formData.password,
rememberMe: formData.rememberMe,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Inloggningen misslyckades');
}
if (data.twoFactorRequired) {
setTwoFactorRequired(true);
setTwoFactorUserId(data.userId);
setLoading(false);
return;
}
// The actual saving of remembered email is now handled by the checkbox onChange
// or here if we want to be 100% sure on success
if (formData.rememberMe) {
localStorage.setItem('remembered_email', formData.emailOrUsername);
} else {
localStorage.removeItem('remembered_email');
}
// Update global auth state
login(data.token, data.user, formData.rememberMe);
router.push('/me');
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Ett oväntat fel uppstod');
}
setLoading(false);
}
};
const handle2FAVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/auth/2fa/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: twoFactorUserId,
code: twoFactorCode,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Verifieringen misslyckades');
}
if (formData.rememberMe) {
localStorage.setItem('remembered_email', formData.emailOrUsername);
} else {
localStorage.removeItem('remembered_email');
}
login(data.token, data.user, formData.rememberMe);
router.push('/me');
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Ett oväntat fel uppstod');
}
setLoading(false);
}
};
const handleGoogleLogin = async () => {
try {
const res = await fetch('/api/auth/google/url');
const data = await res.json();
if (data.url) {
window.location.href = data.url;
} else {
setError('Kunde inte starta Google-inloggning');
}
} catch {
setError('Ett fel uppstod vid Google-inloggning');
}
};
if (authLoading || isAuthenticated) {
return null;
}
return (
<div className="auth-page-container">
<div className="profile-card card-sm">
<h2 className="auth-title">{twoFactorRequired ? '2FA Verifiering' : 'LOGGA IN'}</h2>
{successMessage && (
<div className="auth-success">
{successMessage}
</div>
)}
<p className="auth-subtitle">
{twoFactorRequired
? 'Ange koden från din autentiseringsapp'
: 'Logga in på ditt konto för att fortsätta'
}
</p>
{!twoFactorRequired ? (
<form className="auth-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" htmlFor="email">
E-post eller Användarnamn
</label>
<input
id="email"
name="email"
type="text"
autoComplete="username"
required
className="form-input"
placeholder="Ange din e-post eller användarnamn"
value={formData.emailOrUsername}
onChange={(e) => setFormData({ ...formData, emailOrUsername: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="password">
Lösenord
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="form-input"
placeholder="Ange ditt lösenord"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
</div>
<div className="form-checkbox-group">
<input
id="rememberMe"
type="checkbox"
className="form-checkbox"
checked={formData.rememberMe}
onChange={(e) => {
const checked = e.target.checked;
setFormData(prev => ({ ...prev, rememberMe: checked }));
// If manually unticking, clear it immediately so it doesn't return on refresh
if (!checked) {
localStorage.removeItem('remembered_email');
}
}}
/>
<label htmlFor="rememberMe">Kom ihåg mig</label>
</div>
{error && (
<div className="auth-error">
{error}
</div>
)}
<div className="auth-button-container mt-4">
<button
type="submit"
disabled={loading}
className="auth-button"
>
{loading ? 'Loggar in...' : 'Logga in'}
</button>
<button
type="button"
onClick={handleGoogleLogin}
className="auth-button google"
>
<FcGoogle size={20} />
Fortsätt med Google
</button>
<div className="auth-divider">ELLER</div>
<Link href="/register" className="auth-button secondary">
Registrera
</Link>
</div>
<div className="text-center mt-6">
<Link href="/forgot" className="auth-link text-xs">
Glömt Lösenord?
</Link>
</div>
</form>
) : (
<form className="auth-form" onSubmit={handle2FAVerify}>
<div className="form-group">
<label className="form-label">6-siffrig kod</label>
<input
id="2fa-code"
type="text"
required
className="form-input"
placeholder="000000"
maxLength={6}
value={twoFactorCode}
onChange={(e) => setTwoFactorCode(e.target.value)}
/>
</div>
{error && (
<div className="auth-error">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="auth-button"
>
{loading ? 'Verifierar...' : 'Verifiera & Logga in'}
</button>
<button
type="button"
className="auth-link btn-link-raw block mx-auto mt-4"
onClick={() => setTwoFactorRequired(false)}
>
Tillbaka till inloggning
</button>
</form>
)}
</div>
</div>
);
}

881
src/app/me/page.tsx Normal file
View File

@ -0,0 +1,881 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuth } from '@/context/AuthContext';
import { validateSwedishMobile, normalizeSwedishMobile, validatePersonnummer, validateAddress, lookupSwedishCity, lookupSwedishAddress } from '@/lib/validation';
import { Settings, X } from 'lucide-react';
import '@/styles/Auth.css';
export default function MePage() {
const router = useRouter();
const { user, login, logout, loading: authLoading, isAuthenticated, refreshUser } = useAuth();
const [loading, setLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
name: '',
personnummer: '',
mobile: '',
address: '',
zip_code: '',
city: '',
country: ''
});
// Account Management States
const [actionLoading, setActionLoading] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false);
const [passwords, setPasswords] = useState({
current: '',
new: '',
confirm: ''
});
// 2FA Setup States
const [show2FASetup, setShow2FASetup] = useState(false);
const [qrCode, setQrCode] = useState('');
const [twoFactorCode, setTwoFactorCode] = useState('');
const [twoFactorToken, setTwoFactorToken] = useState('');
const [setupError, setSetupError] = useState('');
const [setupSuccess, setSetupSuccess] = useState('');
const successTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (user) {
setFormData({
name: user.name || '',
personnummer: user.personnummer || '',
mobile: user.mobile || '',
address: user.address || '',
zip_code: user.zip_code || '',
city: user.city || '',
country: user.country || ''
});
}
}, [user]);
// Auto-fetch city when zip code is 5 digits
useEffect(() => {
const fetchCity = async () => {
if (formData.zip_code.length === 5) {
const city = await lookupSwedishCity(formData.zip_code);
if (city) {
setFormData(prev => ({ ...prev, city }));
}
}
};
fetchCity();
}, [formData.zip_code]);
useEffect(() => {
if (!authLoading) {
if (!isAuthenticated) {
router.push('/login');
} else {
setLoading(false);
}
}
}, [authLoading, isAuthenticated, router]);
const handleLogout = () => {
logout();
};
const initiation2FASetup = async () => {
setSetupError('');
setSetupSuccess('');
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/auth/2fa/setup', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Kunde inte initiera 2FA');
setQrCode(data.qrCodeDataUrl);
setTwoFactorToken(data.secret);
setShow2FASetup(true);
} catch (err: unknown) {
setSetupError(err instanceof Error ? err.message : 'Ett oväntat fel uppstod');
}
};
const verifyAndEnable2FA = async (e: React.FormEvent) => {
e.preventDefault();
setSetupError('');
setActionLoading(true);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/auth/2fa/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ code: twoFactorCode })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Felaktig kod');
setSetupSuccess('Tvåfaktorsautentisering har aktiverats!');
setShow2FASetup(false);
setTwoFactorCode('');
if (user) {
const updatedUser = { ...user, two_factor_enabled: true };
login(token || '', updatedUser);
}
} catch (err: unknown) {
setSetupError(err instanceof Error ? err.message : 'Ett oväntat fel uppstod');
} finally {
setActionLoading(false);
}
};
const handleDisable2FA = async () => {
if (!confirm('Är du säker på att du vill inaktivera 2FA? Detta gör ditt konto mindre säkert.')) return;
setActionLoading(true);
setSetupError('');
setSetupSuccess('');
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/auth/2fa/disable', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Kunde inte inaktivera 2FA');
setSetupSuccess('2FA har inaktiverats.');
if (user) {
const updatedUser = { ...user, two_factor_enabled: false };
login(token || '', updatedUser);
}
} catch (err: unknown) {
setSetupError(err instanceof Error ? err.message : 'Ett fel uppstod');
} finally {
setActionLoading(false);
}
};
const handleNewsletterToggle = async () => {
const newValue = !user?.newsletter_subscribed;
setActionLoading(true);
setSetupError('');
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/auth/newsletter/toggle', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ subscribed: newValue })
});
if (!res.ok) throw new Error('Kunde inte uppdatera nyhetsbrev');
if (user) {
const updatedUser = { ...user, newsletter_subscribed: newValue };
login(token || '', updatedUser);
}
if (newValue) {
setSetupSuccess('Välkommen! Du prenumererar nu på vårt nyhetsbrev.');
} else {
setSetupSuccess('Du har avprenumererat dig från vårt nyhetsbrev.');
}
// Reset Timer: Clear existing and start fresh
if (successTimeoutRef.current) clearTimeout(successTimeoutRef.current);
successTimeoutRef.current = setTimeout(() => setSetupSuccess(''), 5000);
} catch (err: unknown) {
setSetupError(err instanceof Error ? err.message : 'Ett fel uppstod');
} finally {
setActionLoading(false);
}
};
const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault();
setSetupError('');
setSetupSuccess('');
setActionLoading(true);
if (formData.name && formData.name.length < 2) {
setSetupError('Namnet är för kort.');
setActionLoading(false);
return;
}
if (formData.personnummer && !validatePersonnummer(formData.personnummer)) {
setSetupError('Ogiltigt personnummer. Använd formatet YYYYMMDD-XXXX.');
setActionLoading(false);
return;
}
if (formData.mobile && !validateSwedishMobile(formData.mobile)) {
setSetupError('Mobilnummeret är ogiltigt. Det måste vara ett svenskt mobilnummer (t.ex. 070-123 45 67).');
setActionLoading(false);
return;
}
if (formData.address && !validateAddress(formData.address)) {
setSetupError('Ogiltig adress. Ange gatuadress och nummer.');
setActionLoading(false);
return;
}
// Verify physical address exists (External API)
try {
const addressExists = await lookupSwedishAddress(formData.address, formData.zip_code, formData.city);
if (!addressExists) {
setSetupError('Adressen verkar inte existera. Vänligen kontrollera stavning och adressnummer.');
setActionLoading(false);
return;
}
} catch (err) {
console.error('Profile address verification error:', err);
// Fail open on network errors
}
const zipRegex = /^\d{5}$/;
if (formData.zip_code && !zipRegex.test(formData.zip_code.replace(/\s/g, ''))) {
setSetupError('Postnummer måste bestå av 5 siffror');
setActionLoading(false);
return;
}
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
full_name: formData.name,
personnummer: formData.personnummer,
mobile: formData.mobile ? normalizeSwedishMobile(formData.mobile) : '',
address: formData.address,
zip_code: formData.zip_code,
city: formData.city
// Country is locked to 'Sverige'
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Kunde inte uppdatera profilen');
const updatedMobile = formData.mobile ? normalizeSwedishMobile(formData.mobile) : '';
if (user) {
const updatedUser = {
...user,
name: formData.name,
personnummer: formData.personnummer,
mobile: updatedMobile,
address: formData.address,
zip_code: formData.zip_code,
city: formData.city,
country: formData.country
};
login(token || '', updatedUser);
}
setFormData(prev => ({ ...prev, mobile: updatedMobile }));
setSetupSuccess('Profilen har uppdaterats!');
setIsEditing(false);
if (successTimeoutRef.current) clearTimeout(successTimeoutRef.current);
successTimeoutRef.current = setTimeout(() => setSetupSuccess(''), 5000);
} catch (err: unknown) {
setSetupError(err instanceof Error ? err.message : 'Ett fel uppstod');
} finally {
setActionLoading(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setSetupError('');
setSetupSuccess('');
if (passwords.new !== passwords.confirm) {
setSetupError('Lösenorden matchar inte');
return;
}
setActionLoading(true);
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
const res = await fetch('/api/auth/change-password', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
currentPassword: passwords.current,
newPassword: passwords.new
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Gick inte att byta lösenord');
setSetupSuccess('Ditt lösenord har uppdaterats!');
setShowChangePassword(false);
setPasswords({ current: '', new: '', confirm: '' });
// Update local user state
if (user) {
login(token || '', { ...user, has_password: true });
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Ett fel uppstod";
setSetupError(msg);
if (msg.includes("Nuvarande lösenord krävs") && !user?.has_password) {
await refreshUser();
setPasswords({ current: "", new: "", confirm: "" });
}
} finally {
setActionLoading(false);
}
};
const handleDeleteAccount = async () => {
setActionLoading(true);
setSetupError('');
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
const res = await fetch('/api/auth/account', {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Kunde inte ta bort kontot');
logout();
router.push('/register?deleted=true');
} catch (err: unknown) {
setSetupError(err instanceof Error ? err.message : 'Ett fel uppstod');
setActionLoading(false);
}
};
if (authLoading || loading) {
return (
<div className="auth-page-container">
<div className="text-xl">Laddar...</div>
</div>
);
}
if (!user) return null;
return (
<div className="auth-page-container">
<div className="card-lg">
<div className="flex items-center justify-between mb-12 border-b border-gray-100 dark:border-white/10 pb-8">
<div className="flex flex-col gap-2">
<h2 className="auth-title" style={{ textAlign: 'left', marginBottom: 0, fontSize: '2.5rem' }}>Min Profil</h2>
{user.email_verified ? (
<span className="status-badge status-verified" style={{ alignSelf: 'flex-start' }}>Verifierad</span>
) : (
<span className="status-badge status-unverified" style={{ alignSelf: 'flex-start' }}>Ej Verifierad</span>
)}
</div>
{user.role === 'admin' && (
<Link href="/admin" className="auth-button secondary btn-admin">
ADMIN VIEW
</Link>
)}
</div>
{(!user.personnummer || !user.address || !user.mobile) && (
<div className="auth-error mb-8 flex items-center gap-3" style={{ background: 'oklch(0.962 0.018 272.314/0.1)', color: 'var(--text-secondary)', border: '1px solid var(--border-color)' }}>
<p className="text-xs font-bold uppercase tracking-wider mb-0">Din profil är inte komplett. Vänligen ange personnummer och adress för att kunna använda alla tjänster.</p>
</div>
)}
<div className="profile-main-grid w-full">
<div className="profile-card">
<div className="profile-card-header">
<div className="header-title-group">
<h3 className="header-title">Personlig Information</h3>
</div>
<div className="flex-shrink-0">
{!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className="p-2 rounded-full transition-colors text-black dark:text-white hover:bg-black/5 dark:hover:bg-white/5 flex items-center justify-center"
aria-label="Redigera profil"
title="Redigera profil"
>
<Settings size={24} className="header-icon" />
</button>
) : (
<button
onClick={() => setIsEditing(false)}
className="p-2 rounded-full transition-colors text-gray-400 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 flex items-center justify-center"
aria-label="Avbryt redigering"
title="Avbryt redigering"
>
<X size={24} className="header-icon" />
</button>
)}
</div>
</div>
<form onSubmit={handleUpdateProfile} className="profile-form-grid">
<div className="profile-form-row">
<div className="form-group">
<label className="form-label">Namn</label>
{isEditing ? (
<input
type="text"
className="form-input form-input-editable w-full"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
) : (
<div className="form-input form-input-readonly">{user.name}</div>
)}
</div>
<div className="form-group">
<label className="form-label">Roll</label>
<div className="form-input form-input-readonly">
<span className={`font-black uppercase ${user.role === 'admin' ? 'text-red-500' : 'text-gray-500'}`}>
{user.role}
</span>
</div>
</div>
</div>
<div className="profile-form-row">
<div className="form-group">
<label className="form-label">Användarnamn</label>
<div className="form-input form-input-readonly">{user.username}</div>
</div>
<div className="form-group">
<label className="form-label">E-postadress</label>
<div className="form-input form-input-readonly">{user.email}</div>
</div>
</div>
<div className="profile-form-row">
<div className="form-group">
<label className="form-label">Personnummer</label>
{isEditing ? (
<input
type="text"
className="form-input form-input-editable w-full"
placeholder="ÅÅÅÅMMDD-XXXX"
maxLength={13}
value={formData.personnummer}
onChange={(e) => {
let val = e.target.value.replace(/\D/g, '');
if (val.length > 8) {
val = val.slice(0, 8) + '-' + val.slice(8, 12);
}
setFormData({ ...formData, personnummer: val });
}}
/>
) : (
<div className="form-input form-input-readonly">{user.personnummer || '-'}</div>
)}
</div>
<div className="form-group">
<label className="form-label">Mobil</label>
{isEditing ? (
<input
type="tel"
className="form-input form-input-editable w-full"
placeholder="+46 70 123 45 67"
value={formData.mobile}
onChange={(e) => setFormData({ ...formData, mobile: e.target.value })}
/>
) : (
<div className="form-input form-input-readonly">{user.mobile || '-'}</div>
)}
</div>
</div>
<div className="form-group">
<label className="form-label">Adress</label>
{isEditing ? (
<input
type="text"
className="form-input form-input-editable w-full"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
/>
) : (
<div className="form-input form-input-readonly">{user.address || '-'}</div>
)}
</div>
<div className="profile-form-row">
<div className="form-group">
<label className="form-label">Postnummer</label>
{isEditing ? (
<input
type="text"
className="form-input form-input-editable w-full"
maxLength={5}
value={formData.zip_code}
onChange={(e) => setFormData({ ...formData, zip_code: e.target.value.replace(/\D/g, '') })}
/>
) : (
<div className="form-input form-input-readonly">{user.zip_code || '-'}</div>
)}
</div>
<div className="form-group">
<label className="form-label">Ort</label>
{isEditing ? (
<input
type="text"
className="form-input form-input-editable w-full"
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
required
/>
) : (
<div className="form-input form-input-readonly">{user.city || '-'}</div>
)}
</div>
</div>
<div className="profile-form-row">
<div className="form-group">
<label className="form-label">Land</label>
<div className="form-input form-input-readonly">Sverige</div>
</div>
<div className="form-group">
<label className="form-label">Medlem sedan</label>
<div className="form-input form-input-readonly">
{new Date(user.created_at).toLocaleDateString('sv-SE')}
</div>
</div>
</div>
{isEditing && (
<div className="mt-8">
<button
type="submit"
className="auth-button success"
disabled={actionLoading}
>
{actionLoading ? 'Sparar...' : 'Spara ändringar'}
</button>
</div>
)}
</form>
<div className="logout-section mt-12">
<button onClick={handleLogout} className="auth-button danger">
Logga ut Nordic Storium
</button>
</div>
</div>
{/* CARD 2: Settings & Security */}
<div className="profile-card">
<div className="profile-card-header">
<div className="header-title-group">
<h3 className="header-title">Inställningar & Säkerhet</h3>
</div>
<div className="flex-shrink-0 invisible pointer-events-none select-none">
<div className="p-2 flex items-center justify-center">
<Settings size={24} />
</div>
</div>
</div>
<div className="settings-internal-grid">
{/* Row 1: Password & Newsletter */}
{/* Password Change */}
{/* Row 1: Password & Newsletter */}
{/* Password Change */}
<div className="bg-white dark:bg-transparent p-6 flex flex-col justify-between">
<div className="mb-6">
<h4 className="font-black text-sm uppercase tracking-wider mb-2">
{user.has_password ? 'Ändra Lösenord' : 'Ställ in Lösenord'}
</h4>
<p className="text-xs text-gray-500 leading-relaxed">
{user.has_password
? 'Det är rekommenderat att byta lösenord med jämna mellanrum.'
: 'Ditt konto saknar lösenord då du använder social inloggning. Ställ in ett för att kunna logga in utan Google.'
}
</p>
</div>
{!showChangePassword ? (
<button
className={user.has_password ? "auth-button danger" : "auth-button success"}
onClick={() => setShowChangePassword(true)}
>
{user.has_password ? 'Ändra' : 'Sätt lösenord'}
</button>
) : (
<div className="animate-fade-in">
<div className="flex justify-between items-center mb-4">
<button
className="auth-button secondary text-sm py-2 px-4"
onClick={() => setShowChangePassword(false)}
>
Tillbaka
</button>
</div>
<form onSubmit={handleChangePassword} className="space-y-4">
{user.has_password && (
<div className="form-group">
<div className="form-label">Nuvarande lösenord</div>
<input
type="password"
required
className="form-input w-full"
value={passwords.current}
onChange={(e) => setPasswords({ ...passwords, current: e.target.value })}
/>
</div>
)}
<div className="form-group">
<div className="form-label">Nytt lösenord</div>
<input
type="password"
required
className="form-input w-full"
value={passwords.new}
onChange={(e) => setPasswords({ ...passwords, new: e.target.value })}
/>
</div>
<div className="form-group">
<div className="form-label">Bekräfta nytt lösenord</div>
<input
type="password"
required
className="form-input w-full"
value={passwords.confirm}
onChange={(e) => setPasswords({ ...passwords, confirm: e.target.value })}
/>
</div>
<button
type="submit"
className="auth-button success mt-4"
disabled={actionLoading}
>
{actionLoading ? 'Sparar...' : 'Bekräfta'}
</button>
</form>
</div>
)}
</div>
{/* Newsletter */}
<div className="bg-white dark:bg-transparent p-6 flex flex-col justify-between">
<div className="mb-6">
<h4 className="font-black text-sm uppercase tracking-wider mb-2">Nyhetsbrev</h4>
<p className="text-xs text-gray-500 leading-relaxed"> de senaste nordic-storium erbjudandena direkt i din inkorg.</p>
</div>
<button
className={`auth-button ${user.newsletter_subscribed ? 'danger' : 'success'}`}
onClick={handleNewsletterToggle}
disabled={actionLoading}
>
{user.newsletter_subscribed ? 'Avprenumerera' : 'Prenumerera'}
</button>
</div>
{/* Row 2: 2FA & Remove Account */}
{/* 2FA */}
<div className="bg-white dark:bg-transparent p-6 flex flex-col justify-between">
<div className="mb-6">
<h4 className="font-black text-sm uppercase tracking-wider mb-2">Säker Inloggning</h4>
<p className="text-xs text-gray-500 leading-relaxed">Säkra ditt konto med tvåfaktorsautentisering vid varje inloggning</p>
</div>
{!show2FASetup ? (
<button
className={`auth-button ${user.two_factor_enabled ? 'danger' : 'success'}`}
onClick={user.two_factor_enabled ? handleDisable2FA : initiation2FASetup}
disabled={actionLoading}
>
{user.two_factor_enabled ? 'Ta bort' : 'Aktivera'}
</button>
) : (
<div className="animate-fade-in">
<div className="flex justify-between items-center mb-4">
<button
className="auth-button secondary text-sm py-2 px-4"
onClick={() => setShow2FASetup(false)}
>
Avbryt
</button>
</div>
<div className="flex flex-col gap-6 w-full">
{/* 1. QR Code */}
<div className="flex justify-center w-full">
<img
src={qrCode}
alt="2FA QR"
className="qr-code-img"
/>
</div>
{/* 2. Manual Code Copy Section */}
<div className="w-full text-center space-y-3">
<p className="text-[10px] uppercase font-black text-gray-500">
Alternativ kod för manuell inmatning
</p>
<div className="form-group w-full">
<div
className="form-input w-full cursor-pointer relative"
onClick={async (e) => {
const element = e.currentTarget;
try {
await navigator.clipboard.writeText(twoFactorToken);
// Add green flash animation
element.classList.add('green-flash');
// Show compact feedback overlay
const overlay = document.createElement('div');
overlay.className = 'copy-success-overlay';
overlay.innerHTML = `
<div class="success-message-box">
<svg class="w-3 h-3 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
</svg>
<span>Kopierad</span>
</div>
`;
element.appendChild(overlay);
// Remove after 1.5 seconds
setTimeout(() => {
element.classList.remove('green-flash');
if (overlay.parentNode === element) {
element.removeChild(overlay);
}
}, 1500);
} catch (err) {
console.error('Kunde inte kopiera:', err);
}
}}
>
<div className="form-label">Klicka & kopiera</div>
<div className="flex items-center justify-center">
<code className="font-mono text-sm font-black tracking-[0.1em] select-all text-center">
{twoFactorToken.replace(/(.{4})/g, '$1 ').trim()}
</code>
</div>
</div>
</div>
</div>
{/* 3. Verification Form */}
<form onSubmit={verifyAndEnable2FA} className="w-full space-y-2 mt-4">
<div className="form-group">
<div className="form-label">Autentiseringskod</div>
<input
type="text"
className="form-input w-full text-left tracking-[0.2em] text-lg"
maxLength={6}
placeholder="000000"
value={twoFactorCode}
onChange={(e) => setTwoFactorCode(e.target.value.replace(/\D/g, ''))}
/>
</div>
<button
type="submit"
className="auth-button success mt-6 w-full"
disabled={actionLoading || twoFactorCode.length !== 6}
>
{actionLoading ? 'Verifierar...' : 'Aktivera Nu'}
</button>
</form>
</div>
</div>
)}
</div>
{/* Remove Account */}
<div className="bg-white dark:bg-transparent p-6 flex flex-col justify-between">
<div className="mb-6">
<h4 className="font-black text-sm uppercase tracking-wider mb-2 text-red-600">Radera Konto</h4>
<p className="text-xs text-gray-500 leading-relaxed">Radera ditt konto och all data kopplade till kontot permanent.</p>
</div>
{!showDeleteConfirm ? (
<button
className="auth-button danger"
onClick={() => setShowDeleteConfirm(true)}
>
Radera konto
</button>
) : (
<div className="animate-fade-in flex flex-col h-full justify-between">
<div className="flex justify-end mb-2">
<button
className="auth-button secondary text-sm py-2 px-4"
onClick={() => setShowDeleteConfirm(false)}
>
Tillbaka
</button>
</div>
<p className="text-sm text-red-600 font-medium mb-4">
Är du säker? Denna åtgärd går inte att ångra.
</p>
<div className="flex gap-2">
<button
className="auth-button danger flex-1"
onClick={handleDeleteAccount}
disabled={actionLoading}
>
{actionLoading ? 'Raderar...' : 'Ja, Radera'}
</button>
</div>
</div>
)}
</div>
</div>
{setupSuccess && (
<div className="auth-success mt-8 animate-fade-in">
{setupSuccess}
</div>
)}
{setupError && (
<div className="auth-error mt-8 animate-fade-in">
{setupError}
</div>
)}
</div>
</div>
</div>
</div>
);
}

466
src/app/messages/page.tsx Normal file
View File

@ -0,0 +1,466 @@
"use client";
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/context/AuthContext';
import { useToast } from '@/components/Toast';
import { Loader2, Send, MessageCircle, Plus, Trash2, CheckCircle, AlertTriangle } from 'lucide-react';
import '@/styles/Messages.css';
interface Conversation {
id: number;
subject: string;
status: 'open' | 'closed';
created_at: string;
updated_at: string;
product_id?: number;
product_name?: string;
unread_count: number;
last_message?: string;
user_name?: string;
user_email?: string;
}
interface Message {
id: number;
sender_id: number;
sender_role: 'customer' | 'admin';
sender_name: string;
content: string;
created_at: string;
is_read: boolean;
}
export default function MessagesPage() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
const { showToast } = useToast();
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [newMessage, setNewMessage] = useState('');
const [sending, setSending] = useState(false);
const [showNewConversation, setShowNewConversation] = useState(false);
const [newSubject, setNewSubject] = useState('');
const [newInitialMessage, setNewInitialMessage] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState<number | null>(null);
const [showResolveConfirm, setShowResolveConfirm] = useState<number | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const activeConversationRef = useRef<Conversation | null>(null);
const lastMessageCountRef = useRef<number>(0);
useEffect(() => {
activeConversationRef.current = activeConversation;
}, [activeConversation]);
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
}
}, [user, authLoading, router]);
const getToken = useCallback(() => localStorage.getItem('token') || sessionStorage.getItem('token'), []);
const fetchConversations = useCallback(async (silent = false) => {
try {
const res = await fetch('/api/conversations', {
headers: { 'Authorization': `Bearer ${getToken()}` }
});
if (res.ok) {
const data = await res.json();
setConversations(data);
}
} catch (err) {
console.error(err);
} finally {
if (!silent) setLoading(false);
}
}, [getToken]);
const fetchMessages = useCallback(async (convId: number, silent = false) => {
try {
const res = await fetch(`/api/conversations/${convId}`, {
headers: { 'Authorization': `Bearer ${getToken()}` }
});
if (res.ok) {
const data = await res.json();
const newCount = data.messages.length;
// Only scroll to bottom if new messages arrived
if (newCount > lastMessageCountRef.current) {
setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}
lastMessageCountRef.current = newCount;
setMessages(data.messages);
if (!silent) {
setConversations(prev => prev.map(c =>
c.id === convId ? { ...c, unread_count: 0 } : c
));
}
}
} catch (err) {
console.error(err);
}
}, [getToken]);
useEffect(() => {
if (user) {
fetchConversations();
}
}, [user, fetchConversations]);
// Real-time polling
useEffect(() => {
if (!user) return;
const pollInterval = setInterval(() => {
fetchConversations(true);
if (activeConversationRef.current) {
fetchMessages(activeConversationRef.current.id, true);
}
}, 3000);
return () => clearInterval(pollInterval);
}, [user, fetchConversations, fetchMessages]);
const openConversation = async (conv: Conversation) => {
setActiveConversation(conv);
lastMessageCountRef.current = 0; // Reset so it scrolls on open
await fetchMessages(conv.id);
};
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || !activeConversation) return;
setSending(true);
try {
const res = await fetch(`/api/conversations/${activeConversation.id}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${getToken()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ content: newMessage })
});
if (res.ok) {
setNewMessage('');
lastMessageCountRef.current = 0; // Scroll to new message
await fetchMessages(activeConversation.id);
showToast('success', 'Meddelande skickat');
}
} catch (err) {
showToast('error', 'Kunde inte skicka');
} finally {
setSending(false);
}
};
const handleCreateConversation = async (e: React.FormEvent) => {
e.preventDefault();
if (!newSubject.trim() || !newInitialMessage.trim()) return;
setSending(true);
try {
const res = await fetch('/api/conversations', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getToken()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
subject: newSubject,
message: newInitialMessage
})
});
if (res.ok) {
const data = await res.json();
setShowNewConversation(false);
setNewSubject('');
setNewInitialMessage('');
showToast('success', 'Ärende skapat');
await fetchConversations();
openConversation({ id: data.conversationId, subject: newSubject } as Conversation);
}
} catch (err) {
showToast('error', 'Något gick fel');
} finally {
setSending(false);
}
};
const handleDeleteConversation = async (id: number) => {
try {
const res = await fetch(`/api/conversations/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${getToken()}` }
});
if (res.ok) {
showToast('success', 'Konversation borttagen');
setShowDeleteConfirm(null);
if (activeConversation?.id === id) {
setActiveConversation(null);
setMessages([]);
}
await fetchConversations();
}
} catch (err) {
showToast('error', 'Kunde inte ta bort');
}
};
const handleResolveConversation = async (id: number) => {
try {
const res = await fetch(`/api/conversations/${id}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${getToken()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ action: 'resolve' })
});
if (res.ok) {
showToast('success', 'Ärende markerat som löst');
setShowResolveConfirm(null);
if (activeConversation?.id === id) {
setActiveConversation(null);
setMessages([]);
}
await fetchConversations();
}
} catch (err) {
showToast('error', 'Kunde inte lösa ärende');
}
};
const isAdmin = user?.role === 'admin';
if (authLoading || loading) {
return (
<div className="h-screen flex items-center justify-center">
<Loader2 className="animate-spin" size={48} />
</div>
);
}
return (
<div className="messages-container">
<div className="messages-header">
<h1>Meddelanden</h1>
{!showNewConversation && !isAdmin && (
<button onClick={() => setShowNewConversation(true)} className="msg-btn">
<Plus size={16} />
Nytt ärende
</button>
)}
</div>
{/* New Conversation Form */}
{showNewConversation && (
<div className="msg-form-card">
<h2>Nytt meddelande</h2>
<form onSubmit={handleCreateConversation}>
<input
type="text"
value={newSubject}
onChange={(e) => setNewSubject(e.target.value)}
className="msg-input"
placeholder="Ämne"
required
/>
<textarea
value={newInitialMessage}
onChange={(e) => setNewInitialMessage(e.target.value)}
className="msg-input"
placeholder="Beskriv ditt ärende..."
style={{ minHeight: '100px', resize: 'vertical' }}
required
/>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button type="button" onClick={() => setShowNewConversation(false)} className="msg-btn">
Avbryt
</button>
<button type="submit" disabled={sending} className="msg-btn primary">
<Send size={16} />
{sending ? 'Skickar...' : 'Skicka'}
</button>
</div>
</form>
</div>
)}
<div className="messages-grid">
{/* Conversation List */}
<div className="conversation-list">
{conversations.length === 0 ? (
<div className="empty-state">
<MessageCircle size={48} />
<p>Inga meddelanden ännu</p>
</div>
) : (
conversations.map(conv => (
<div
key={conv.id}
onClick={() => openConversation(conv)}
className={`conversation-item ${activeConversation?.id === conv.id ? 'active' : ''} ${conv.status === 'closed' ? 'closed' : ''}`}
>
<div className="conversation-subject">
{conv.status === 'closed' && <CheckCircle size={14} style={{ color: '#10b981' }} />}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{conv.subject}</span>
{conv.unread_count > 0 && (
<span className="unread-badge">{conv.unread_count}</span>
)}
</div>
{isAdmin && conv.user_name && (
<div style={{ fontSize: '0.7rem', color: activeConversation?.id === conv.id ? 'inherit' : '#3b82f6', marginBottom: '0.25rem' }}>
{conv.user_name}
</div>
)}
{conv.last_message && (
<div className="conversation-preview">{conv.last_message}</div>
)}
<div className="conversation-meta">
{new Date(conv.updated_at).toLocaleDateString('sv-SE')}
</div>
{activeConversation?.id === conv.id && (
<div className="conversation-actions">
{isAdmin && conv.status === 'open' && (
<button
onClick={(e) => { e.stopPropagation(); setShowResolveConfirm(conv.id); }}
className="action-btn resolve"
title="Markera som löst"
>
<CheckCircle size={14} />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); setShowDeleteConfirm(conv.id); }}
className="action-btn delete"
title="Ta bort"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
))
)}
</div>
{/* Chat Panel */}
<div className="chat-panel">
{activeConversation ? (
<>
<div className="chat-header">
<h2>{activeConversation.subject}</h2>
{activeConversation.product_name && (
<div className="product-link">Produkt: {activeConversation.product_name}</div>
)}
{activeConversation.status === 'closed' && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem', color: '#10b981', marginTop: '0.25rem' }}>
<CheckCircle size={12} /> Löst
</span>
)}
</div>
<div className="chat-messages">
{messages.map(msg => {
const isMine = (isAdmin && msg.sender_role === 'admin') || (!isAdmin && msg.sender_role === 'customer');
return (
<div key={msg.id} className={`message-wrapper ${isMine ? 'mine' : 'theirs'}`}>
<div className="message-bubble">
<div className="message-sender">
{msg.sender_name} {new Date(msg.created_at).toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' })}
</div>
<div className="message-content">{msg.content}</div>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
{activeConversation.status !== 'closed' ? (
<form onSubmit={handleSendMessage} className="chat-input-area">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Skriv ett meddelande..."
/>
<button type="submit" disabled={sending || !newMessage.trim()} className="msg-btn primary">
<Send size={18} />
</button>
</form>
) : (
<div className="resolved-banner">
Detta ärende är markerat som löst. Kontakta oss gärna igen om du har fler frågor.
</div>
)}
</>
) : (
<div className="empty-state">
<MessageCircle size={64} />
<p>Välj en konversation</p>
</div>
)}
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm !== null && (
<div className="modal-overlay">
<div className="modal-content">
<div className="modal-header danger">
<AlertTriangle size={24} />
<span>Ta bort konversation?</span>
</div>
<p className="modal-text">
Detta kommer permanent radera konversationen och alla meddelanden.
</p>
<div className="modal-actions">
<button onClick={() => setShowDeleteConfirm(null)} className="msg-btn">
Avbryt
</button>
<button onClick={() => handleDeleteConversation(showDeleteConfirm)} className="msg-btn danger">
Ta bort
</button>
</div>
</div>
</div>
)}
{/* Resolve Confirmation Modal */}
{showResolveConfirm !== null && (
<div className="modal-overlay">
<div className="modal-content">
<div className="modal-header success">
<CheckCircle size={24} />
<span>Markera som löst?</span>
</div>
<p className="modal-text">
Kunden kommer ett mejl att ärendet är löst. Konversationen döljs från din vy.
</p>
<div className="modal-actions">
<button onClick={() => setShowResolveConfirm(null)} className="msg-btn">
Avbryt
</button>
<button onClick={() => handleResolveConversation(showResolveConfirm)} className="msg-btn success">
Markera löst
</button>
</div>
</div>
</div>
)}
</div>
);
}

106
src/app/om-oss/Omoss.css Normal file
View File

@ -0,0 +1,106 @@
/* OmOss Page Styles */
.about-container {
min-height: calc(100vh - 80px);
padding: 1.5rem max(0.5rem, 3vw);
background-color: var(--content-bg);
transition: background-color 0.3s ease;
overflow-x: hidden;
}
.about-container .about-content {
max-width: 1280px;
margin: 0 auto;
width: 100%;
}
/* Header Section */
.about-container .about-header {
text-align: center;
margin-bottom: 2rem;
}
.about-container .intro-text {
font-size: clamp(1.1rem, 3vw, 1.3rem);
font-weight: 600;
color: var(--nav-hover);
}
.about-container .about-details {
max-width: 1400px;
margin: 0 auto;
}
/* Areas Section */
.about-container .areas {
margin-top: clamp(1.2rem, 3vw, 1.8rem);
}
.about-container .areas-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: clamp(1rem, 2.5vw, 1.25rem);
}
.about-container .area-card {
background: var(--card-bg);
border-radius: 12px;
padding: clamp(1rem, 2.8vw, 1.4rem);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(97, 218, 251, 0.15);
}
.about-container .area-card h3 {
font-family: 'Montserrat', sans-serif;
font-size: clamp(1rem, 2.6vw, 1.2rem);
color: var(--text-color);
margin: 0 0 0.35rem 0;
}
.about-container .area-card p {
margin: 0;
color: var(--text-secondary);
font-size: clamp(0.9rem, 2.4vw, 0.98rem);
line-height: 1.55;
}
/* Page Content Styling */
.about-container .section-title {
color: var(--text-color);
}
.about-container .text-content {
color: var(--text-secondary);
}
.about-container strong {
color: var(--text-color);
}
/* Responsive Design */
@media (max-width: 1100px) {
.about-container .areas-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.about-container h1 {
font-size: clamp(1.6rem, 6vw, 2.2rem);
}
.about-container h2 {
font-size: clamp(1.3rem, 5vw, 1.6rem);
}
}
@media (max-width: 640px) {
.about-container .areas-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 540px) {
.about-container p {
font-size: clamp(0.85rem, 4vw, 0.95rem);
}
}

91
src/app/om-oss/page.tsx Normal file
View File

@ -0,0 +1,91 @@
import Head from "next/head";
import Image from "next/image";
import ContactGrid from "../../components/ContactGrid";
import "./Omoss.css";
export default function About() {
return (
<>
<Head>
<title>Om oss Nordic Storium</title>
<meta
name="description"
content="Nordic Storium säljer kvalitetsmöbler och kontorsinredning till oslagbara priser. Vi köper in partier från konkursbon och företag."
/>
<link rel="canonical" href="https://www.bbsmobler.se/om-oss" />
</Head>
<div className="container mx-auto px-4 py-12 about-container">
<div className="max-w-4xl mx-auto about-content">
<h1 className="text-4xl font-bold mb-8 text-center section-title">Om Nordic Storium</h1>
<div className="flex flex-col md:flex-row gap-8 mb-16 items-center">
<div className="flex-1 space-y-4 text-lg leading-relaxed text-content">
<p>
<strong>Välkommen till Nordic Storium!</strong> Vi öppnade dörrarna i början av 2026 med en tydlig vision: att erbjuda högkvalitativa möbler till priser som alla har råd med.
</p>
<p>
Vår affärsidé är lika enkel som den är smart. Vi specialiserar oss att förvärva stora partier av kontorsmöbler, stolar och inredning från företag som har avvecklats, flyttat eller gått i konkurs. Detta gör att vi kan sälja produkter av absolut högsta kvalitet ofta kända designmärken till en bråkdel av nypriset.
</p>
<p>
Oavsett om du söker ergonomiska kontorsstolar, snygga konferensbord eller en bekväm soffa till loungen, har vi något för dig. Och det bästa av allt? Vi erbjuder <strong>fri leverans</strong> om du befinner dig i närheten av Stockholm!
</p>
</div>
<div className="flex-1 space-y-4 md:space-y-6 w-full max-w-md mx-auto md:max-w-none">
<div className="relative h-48 md:h-64 w-[85%] md:w-full rounded-2xl overflow-hidden shadow-xl border-4 border-white/20 transform hover:scale-[1.02] transition-transform duration-300">
<Image
src="/artifacts/omoss1.png"
alt="Kontorsmöbler lager"
fill
className="object-cover"
/>
</div>
<div className="relative h-40 md:h-64 w-[75%] md:w-full rounded-2xl overflow-hidden shadow-xl border-4 border-white/20 transform hover:scale-[1.02] transition-transform duration-300 ml-auto md:ml-8 -mt-6 md:-mt-12 z-10">
<Image
src="/artifacts/omoss2.png"
alt="Ergonomiska stolar (Kinnarps)"
fill
className="object-cover"
/>
</div>
</div>
</div>
<div className="p-8 rounded-2xl mb-12 border border-[var(--border-color)] bg-[var(--card-bg)] shadow-lg">
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)]">Varför handla av oss?</h2>
<ul className="space-y-3 text-content">
<li className="flex items-start gap-3">
<svg className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span><strong>Hållbart val:</strong> Att återanvända kvalitetsmöbler är en vinst för både plånboken och miljön.</span>
</li>
<li className="flex items-start gap-3">
<svg className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span><strong>Otroliga priser:</strong> Spara upp till 70-80% jämfört med att köpa nytt.</span>
</li>
<li className="flex items-start gap-3">
<svg className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span><strong>Snabb leverans:</strong> Vi har det mesta lager och kan leverera omgående.</span>
</li>
<li className="flex items-start gap-3">
<svg className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span><strong>Personlig service:</strong> Vi hjälper dig hitta rätt lösning för ditt kontor eller hem.</span>
</li>
</ul>
</div>
</div>
</div>
<ContactGrid />
</>
);
}

146
src/app/page.tsx Normal file
View File

@ -0,0 +1,146 @@
import Head from "next/head";
import Link from "next/link";
import { query } from "../lib/db";
import CategoryCard from "../components/CategoryCard";
import ProductCard from "../components/products/ProductCard";
import ContactGrid from "../components/ContactGrid";
import CategoryBanner from "../components/CategoryBanner";
import HorizontalCarousel from "../components/HorizontalCarousel";
// Interfaces for DB results
interface Category {
id: number;
name: string;
image_url: string;
description: string;
}
interface Product {
id: number;
name: string;
description: string;
price: number;
original_price?: number;
primary_image?: string;
images: string[];
stock_status: 'in_stock' | 'out_of_stock';
product_condition: string;
badge_text?: string;
badge_color?: string;
created_at: string;
}
async function getCategories() {
// Only show categories marked for homepage display (max 8)
const rows = await query("SELECT * FROM categories WHERE show_on_homepage = 1 LIMIT 8") as Category[];
return rows;
}
async function getPopularProducts() {
// Select products marked for homepage display (max 8)
const rows = await query(`
SELECT p.*,
(SELECT pi.image_url FROM product_images pi WHERE pi.product_id = p.id ORDER BY pi.is_primary DESC, pi.display_order ASC LIMIT 1) as primary_image
FROM products p
WHERE p.show_on_homepage = 1
ORDER BY p.created_at DESC
LIMIT 8
`) as Product[];
return rows.map(p => ({ ...p, images: p.primary_image ? [p.primary_image] : [] }));
}
async function getNewProducts() {
// New products are the latest uploaded ones
const rows = await query(`
SELECT p.*,
(SELECT pi.image_url FROM product_images pi WHERE pi.product_id = p.id ORDER BY pi.is_primary DESC, pi.display_order ASC LIMIT 1) as primary_image
FROM products p
ORDER BY p.created_at DESC
LIMIT 8
`) as Product[];
return rows.map(p => ({ ...p, images: p.primary_image ? [p.primary_image] : [] }));
}
export default async function Home() {
const categories = await getCategories();
const popularProducts = await getPopularProducts();
const newProducts = await getNewProducts();
return (
<>
<Head>
<title>Nordic Storium Kvalitetsmöbler till konkurspriser</title>
<meta
name="description"
content="Vi säljer kontorsmöbler, stolar och inredning från konkursbon och företag. Hög kvalitet till låga priser. Fri frakt nära Stockholm."
/>
<link rel="canonical" href="https://www.nordicstorium.se/" />
</Head>
{/* Categories Section */}
<div className="container mx-auto px-4 py-8">
<section>
<div className="flex flex-row justify-between items-end mb-4 px-2">
<h2 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white leading-tight">Populära Kategorier</h2>
<Link href="/products" className="text-blue-600 font-semibold hover:underline text-sm md:text-base whitespace-nowrap">
Se alla &rarr;
</Link>
</div>
{/* Persistent Horizontal Scroll - Centered if possible */}
<HorizontalCarousel>
{categories.map((category) => (
<div key={category.id} className="flex-shrink-0 w-36 md:w-48 lg:w-56 snap-start">
<CategoryCard category={category} />
</div>
))}
</HorizontalCarousel>
</section>
</div>
{/* Combined Banner with Info */}
<CategoryBanner />
{/* Products Section */}
<div className="container mx-auto px-4 py-12 space-y-16">
{/* Popular Products */}
<section>
<div className="flex flex-row justify-between items-end mb-4 px-2">
<h2 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white leading-tight">Populära Produkter</h2>
<Link href="/products" className="text-blue-600 font-semibold hover:underline text-sm md:text-base whitespace-nowrap">
Visa alla &rarr;
</Link>
</div>
<HorizontalCarousel>
{popularProducts.map((product) => (
<div key={product.id} className="flex-shrink-0 w-64 md:w-72 lg:w-80 snap-start">
<ProductCard product={product} />
</div>
))}
</HorizontalCarousel>
</section>
{/* New Products */}
<section>
<div className="flex flex-row justify-between items-end mb-4 px-2">
<h2 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white leading-tight">Nyinkommet</h2>
<Link href="/products" className="text-blue-600 font-semibold hover:underline text-sm md:text-base whitespace-nowrap">
Se alla &rarr;
</Link>
</div>
<HorizontalCarousel>
{newProducts.map((product) => (
<div key={product.id} className="flex-shrink-0 w-64 md:w-72 lg:w-80 snap-start">
<ProductCard product={product} />
</div>
))}
</HorizontalCarousel>
</section>
</div>
{/* Contact Grid */}
<ContactGrid />
</>
);
}

View File

@ -0,0 +1,47 @@
import { notFound } from 'next/navigation';
import pool from '@/lib/db';
import { RowDataPacket } from 'mysql2';
import ProductCatalog from '@/components/products/ProductCatalog';
import { Suspense } from 'react';
import { Loader2 } from 'lucide-react';
interface PageProps {
params: Promise<{ slug: string[] }>;
}
export default async function CategoryPage({ params }: PageProps) {
const { slug } = await params;
// Simple logic: Take the last segment as the category name
const categorySlug = slug[slug.length - 1];
// Convert "office-chairs" -> "Office Chairs"
// Heuristic: matching case-insensitive
const categoryNameCandidate = categorySlug.replace(/-/g, ' ');
try {
const [rows] = await pool.query<RowDataPacket[]>(
'SELECT id, name FROM categories WHERE name = ? OR name LIKE ? LIMIT 1',
[categoryNameCandidate, categoryNameCandidate]
);
if (rows.length === 0) {
return notFound();
}
const category = rows[0];
return (
<Suspense fallback={<div className="p-10 text-center"><Loader2 className="animate-spin mx-auto" /></div>}>
<ProductCatalog
initialCategoryId={category.id.toString()}
initialCategoryName={category.name}
/>
</Suspense>
);
} catch (error) {
console.error('Category Page Error:', error);
return <div>Error loading category</div>;
}
}

View File

@ -0,0 +1,572 @@
"use client";
import { useState, useEffect, Suspense } from 'react';
import { notFound, useRouter } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';
import { Loader2, Check, ShoppingCart, Heart, ArrowLeft, Truck, ShieldCheck, RefreshCw, ChevronLeft, ChevronRight, MessageCircle, X } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { useToast } from '@/components/Toast';
import ProductCard from '@/components/products/ProductCard';
import HorizontalCarousel from '@/components/HorizontalCarousel';
import '@/styles/Products.css';
interface Product {
id: number;
name: string;
description: string;
price: number;
original_price?: number;
images: string[];
primary_image?: string;
category_names: string[];
stock_status: 'in_stock' | 'out_of_stock';
stock: number;
brand?: string;
product_condition: string;
material?: string;
color?: string;
width?: number;
height?: number;
depth?: number;
badge_text?: string;
badge_color?: string;
}
const CONDITION_MAP: Record<string, string> = {
'New': 'Ny',
'Excellent': 'Utmärkt',
'Good': 'Bra',
'Fair': 'Okej',
'Poor': 'Sliten',
'new': 'Ny',
'excellent': 'Utmärkt',
'good': 'Bra',
'fair': 'Okej',
'poor': 'Sliten'
};
// Consistent number formatting to avoid hydration mismatch
function formatPrice(num: number): string {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
interface PageProps {
params: Promise<{ id: string }>;
}
export default function ProductDetailPage({ params }: PageProps) {
const { user, isAuthenticated } = useAuth();
const router = useRouter();
const { showToast } = useToast();
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [activeImageIndex, setActiveImageIndex] = useState(0);
const [id, setId] = useState<string>("");
const [showContactModal, setShowContactModal] = useState(false);
const [showLightbox, setShowLightbox] = useState(false);
const [contactMessage, setContactMessage] = useState('');
const [sending, setSending] = useState(false);
useEffect(() => {
params.then(p => setId(p.id));
}, [params]);
useEffect(() => {
if (!id) return;
const fetchProduct = async () => {
try {
const res = await fetch(`/api/products/${id}`);
if (!res.ok) {
if (res.status === 404) notFound();
throw new Error('Failed to load product');
}
const data = await res.json();
setProduct(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
fetchProduct();
}, [id]);
const handleContactClick = () => {
if (!isAuthenticated) {
showToast('info', 'Du måste logga in för att skicka meddelanden');
router.push('/login');
return;
}
setShowContactModal(true);
};
const handleSendContact = async () => {
if (!contactMessage.trim()) {
showToast('error', 'Skriv ett meddelande');
return;
}
setSending(true);
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
const res = await fetch('/api/conversations', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
product_id: product?.id,
subject: `Artikel #${product?.id}`,
message: contactMessage
})
});
if (res.ok) {
showToast('success', 'Meddelande skickat! Vi återkommer snart.');
setShowContactModal(false);
setContactMessage('');
} else {
const data = await res.json();
showToast('error', data.error || 'Kunde inte skicka meddelande');
}
} catch (err) {
showToast('error', 'Något gick fel');
} finally {
setSending(false);
}
};
if (loading) return <div className="h-screen flex items-center justify-center"><Loader2 className="animate-spin" size={48} /></div>;
if (!product) return <div className="p-10 text-center">Produkt hittades inte</div>;
const hasDiscount = product.original_price && product.original_price > product.price;
const discountPercentage = hasDiscount
? Math.round(((product.original_price! - product.price) / product.original_price!) * 100)
: 0;
return (
<div className="product-detail-container">
<Link href="/products" className="product-detail-back">
<ArrowLeft size={14} /> Tillbaka till produkter
</Link>
<div className="product-detail-grid">
{/* Left: Image Gallery */}
<div className="space-y-4">
<div className="aspect-[4/3] relative rounded-2xl overflow-hidden bg-gray-100 dark:bg-gray-800 border border-[var(--border-color)] group cursor-zoom-in" onClick={() => setShowLightbox(true)}>
{product.images.length > 0 ? (
<Image
key={product.images[activeImageIndex]}
src={product.images[activeImageIndex]}
alt={product.name}
fill
className="object-cover transition-opacity duration-300"
sizes="(max-width: 768px) 100vw, 50vw"
priority
unoptimized={product.images[activeImageIndex].startsWith('http')}
/>
) : (
<div className="flex items-center justify-center h-full text-gray-400">Ingen bild</div>
)}
{product.badge_text && (
<div
className="absolute top-4 left-4 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider text-white shadow-md z-10"
style={{ backgroundColor: product.badge_color || '#000' }}
>
{product.badge_text}
</div>
)}
{/* Navigation Arrows */}
{product.images.length > 1 && (
<div onClick={(e) => e.stopPropagation()}>
<button
onClick={(e) => {
e.stopPropagation();
setActiveImageIndex(prev => (prev === 0 ? product.images.length - 1 : prev - 1));
}}
className="absolute left-3 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/80 dark:bg-black/50 rounded-full flex items-center justify-center transition-opacity z-20 hover:bg-white dark:hover:bg-black"
aria-label="Previous image"
>
<ChevronLeft size={24} className="text-gray-800 dark:text-white" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setActiveImageIndex(prev => (prev === product.images.length - 1 ? 0 : prev + 1));
}}
className="absolute right-3 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/80 dark:bg-black/50 rounded-full flex items-center justify-center transition-opacity z-20 hover:bg-white dark:hover:bg-black"
aria-label="Next image"
>
<ChevronRight size={24} className="text-gray-800 dark:text-white" />
</button>
</div>
)}
{/* Dot Indicators */}
{product.images.length > 1 && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-20" onClick={(e) => e.stopPropagation()}>
{product.images.map((_, idx) => (
<button
key={idx}
onClick={() => setActiveImageIndex(idx)}
className={`w-2.5 h-2.5 rounded-full transition-all ${activeImageIndex === idx ? 'bg-white scale-125' : 'bg-white/50 hover:bg-white/80'}`}
aria-label={`Go to image ${idx + 1}`}
/>
))}
</div>
)}
</div>
{/* Thumbnail Strip */}
{product.images.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-2">
{product.images.map((img, idx) => (
<button
key={idx}
onClick={() => setActiveImageIndex(idx)}
className={`flex-shrink-0 w-20 h-20 relative overflow-hidden border-2 transition-all ${activeImageIndex === idx ? 'border-[var(--text-color)]' : 'border-[var(--border-color)] hover:border-[var(--text-color)]'}`}
>
<Image
src={img}
alt={`${product.name} thumbnail ${idx + 1}`}
fill
className="object-cover"
sizes="80px"
unoptimized={img.startsWith('http')}
/>
</button>
))}
</div>
)}
</div>
{/* Right: Product Details */}
<div className="flex flex-col">
<div className="mb-2 text-[var(--foreground)] opacity-80">
<span className="text-sm font-bold uppercase tracking-wide">
{product.brand || 'Okänt märke'}
</span>
<span className="mx-2 opacity-30"></span>
<span className="text-sm font-medium uppercase">
{product.category_names.length > 0 ? product.category_names.join(', ') : 'OKÄND KATEGORI'}
</span>
</div>
<div className="flex items-center justify-between gap-4 mb-4">
<h1 className="text-3xl md:text-4xl font-bold">{product.name}</h1>
</div>
<div className="mb-6">
{hasDiscount && (
<div className="flex items-center gap-2 mb-1">
<span className="text-gray-500 line-through text-lg">
{formatPrice(product.original_price!)} kr
</span>
<span className="text-red-600 text-sm font-bold">
-{discountPercentage}%
</span>
</div>
)}
<div className="flex items-center gap-4">
<div className="text-4xl font-black text-[var(--foreground)]">
{formatPrice(product.price)} kr
</div>
<div className={`flex items-center gap-2 text-sm font-bold flex-shrink-0 ${product.stock_status === 'in_stock' ? 'text-green-600' : 'text-red-500'}`}>
<div className={`w-2 h-2 rounded-full ${product.stock_status === 'in_stock' ? 'bg-green-600' : 'bg-red-500'}`} />
{product.stock_status === 'in_stock' ? 'I lager' : 'Slutsåld'}
</div>
</div>
</div>
{/* Description Section */}
<div className="mb-8">
<h3 className="text-sm font-black uppercase tracking-wider text-[var(--foreground)] mb-3">Beskrivning</h3>
<p className="text-[var(--foreground)] opacity-90 leading-relaxed whitespace-pre-line text-sm md:text-base">
{product.description}
</p>
</div>
{/* Specs Grid */}
<div className="mb-8 p-0">
<h3 className="text-sm font-black uppercase tracking-wider text-[var(--foreground)] mb-3">Produktdetaljer</h3>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div className="text-gray-500 uppercase text-[10px] font-black tracking-widest">Kategori</div>
<div className="font-bold text-[var(--foreground)] uppercase">{product.category_names.join(', ')}</div>
<div className="text-gray-500 uppercase text-[10px] font-black tracking-widest">Skick</div>
<div className="font-bold text-[var(--foreground)] uppercase">{CONDITION_MAP[product.product_condition] || product.product_condition}</div>
{product.material && (
<>
<div className="text-gray-500 uppercase text-[10px] font-black tracking-widest">Material</div>
<div className="font-bold text-[var(--foreground)] uppercase">{product.material}</div>
</>
)}
{product.color && (
<>
<div className="text-gray-500 uppercase text-[10px] font-black tracking-widest">Färg</div>
<div className="font-bold text-[var(--foreground)] uppercase">{product.color}</div>
</>
)}
{product.width && (
<>
<div className="text-gray-500 uppercase text-[10px] font-black tracking-widest">Mått</div>
<div className="font-bold text-[var(--foreground)] uppercase">{product.width}x{product.height}x{product.depth} cm</div>
</>
)}
<div className="text-gray-500 uppercase text-[10px] font-black tracking-widest">Lager</div>
<div className="font-bold text-[var(--foreground)] uppercase">{product.stock} st</div>
</div>
</div>
{/* Actions */}
<div className="mt-auto">
<button
onClick={handleContactClick}
style={{
width: '100%',
background: 'var(--card-bg)',
color: 'var(--text-color)',
border: '1px solid var(--border-color)',
padding: '1.25rem 1.5rem',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25rem',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--text-color)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-color)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<MessageCircle size={20} />
<span style={{ fontWeight: 700, fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '1.5px' }}>Chatta Med Oss</span>
</div>
<span style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', fontWeight: 500 }}>Svarstid 10-15 minuter</span>
</button>
</div>
</div>
</div>
{/* Contact Modal */}
{showContactModal && (
<div style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(4px)',
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem'
}}>
<div style={{
background: 'var(--card-bg)',
border: '1px solid var(--border-color)',
padding: '2rem',
maxWidth: '450px',
width: '100%'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '0.1em', margin: 0 }}>Skicka förfrågan</h3>
<button onClick={() => setShowContactModal(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.25rem' }}>
<X size={20} />
</button>
</div>
<div style={{ marginBottom: '1rem', padding: '1rem', background: 'var(--input-bg)', border: '1px solid var(--border-color)' }}>
<p style={{ fontSize: '0.65rem', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '1px', color: 'var(--text-secondary)', marginBottom: '0.25rem' }}>Ämne</p>
<p style={{ fontWeight: 700, margin: 0 }}>Artikel #{product.id}</p>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontSize: '0.65rem', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '1px', color: 'var(--text-secondary)', marginBottom: '0.5rem' }}>Ditt meddelande</label>
<textarea
value={contactMessage}
onChange={(e) => setContactMessage(e.target.value)}
placeholder="Jag är intresserad av denna produkt..."
style={{
width: '100%',
padding: '1rem',
border: '1px solid var(--border-color)',
background: 'var(--input-bg)',
color: 'var(--text-color)',
fontSize: '0.9rem',
minHeight: '120px',
resize: 'vertical',
outline: 'none',
boxSizing: 'border-box'
}}
/>
</div>
<button
onClick={handleSendContact}
disabled={sending}
style={{
width: '100%',
background: 'var(--text-color)',
color: 'var(--background)',
border: '1px solid var(--text-color)',
padding: '1.1rem',
borderRadius: '4px',
fontWeight: 700,
fontSize: '0.85rem',
cursor: sending ? 'not-allowed' : 'pointer',
textTransform: 'uppercase',
letterSpacing: '1.5px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.75rem',
opacity: sending ? 0.4 : 1
}}
>
{sending ? <Loader2 className="animate-spin" size={18} /> : <MessageCircle size={18} />}
{sending ? 'Skickar...' : 'Skicka meddelande'}
</button>
</div>
</div>
)}
{/* Highlights - Centralized below the product section */}
<div className="mt-16 py-12 border-t border-[var(--border-color)]">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto text-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 bg-blue-50 dark:bg-blue-900/20 rounded-full flex items-center justify-center text-blue-600 mb-4">
<Truck size={24} />
</div>
<h4 className="font-bold text-lg">Snabb leverans</h4>
<p className="text-sm text-gray-500">2-4 arbetsdagar</p>
</div>
<div className="flex flex-col items-center">
<div className="w-12 h-12 bg-green-50 dark:bg-green-900/20 rounded-full flex items-center justify-center text-green-600 mb-4">
<ShieldCheck size={24} />
</div>
<h4 className="font-bold text-lg">Kvalitetskontrollerad</h4>
<p className="text-sm text-gray-500">Testad & rensad</p>
</div>
<div className="flex flex-col items-center">
<div className="w-12 h-12 bg-purple-50 dark:bg-purple-900/20 rounded-full flex items-center justify-center text-purple-600 mb-4">
<RefreshCw size={24} />
</div>
<h4 className="font-bold text-lg">Trygg e-handel</h4>
<p className="text-sm text-gray-500">14 dagars ångerrätt</p>
</div>
</div>
</div>
{/* Lightbox Modal */}
{showLightbox && product && (
<div className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center p-4" onClick={() => setShowLightbox(false)}>
<button
className="absolute top-4 right-4 text-white/50 hover:text-white transition-colors"
onClick={() => setShowLightbox(false)}
>
<X size={32} />
</button>
<div className="relative w-full max-w-5xl h-[80vh]" onClick={e => e.stopPropagation()}>
<Image
src={product.images[activeImageIndex]}
alt={product.name}
fill
className="object-contain"
sizes="100vw"
priority
unoptimized={product.images[activeImageIndex].startsWith('http')}
/>
{product.images.length > 1 && (
<>
<button
onClick={() => setActiveImageIndex(prev => (prev === 0 ? product.images.length - 1 : prev - 1))}
className="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/50 text-white rounded-full flex items-center justify-center hover:bg-black/80 transition-colors"
>
<ChevronLeft size={32} />
</button>
<button
onClick={() => setActiveImageIndex(prev => (prev === product.images.length - 1 ? 0 : prev + 1))}
className="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/50 text-white rounded-full flex items-center justify-center hover:bg-black/80 transition-colors"
>
<ChevronRight size={32} />
</button>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
{product.images.map((_, idx) => (
<button
key={idx}
onClick={() => setActiveImageIndex(idx)}
className={`w-3 h-3 rounded-full transition-all ${activeImageIndex === idx ? 'bg-white' : 'bg-white/30 hover:bg-white/50'}`}
/>
))}
</div>
</>
)}
</div>
</div>
)}
{/* Similar Products Section */}
<SimilarProducts currentProductId={product.id} />
</div>
);
}
// Import at the top will be handled separately if needed, but assuming I can't double-tool properly.
// Wait, I need to add import.
function SimilarProducts({ currentProductId }: { currentProductId: number }) {
const [products, setProducts] = useState<Product[]>([]);
useEffect(() => {
// Fetch random products as "similar" for now to simulate recommendation
// In a real app, pass category IDs or tags
const fetchSimilar = async () => {
try {
// Limit doubled to 8 per user request
const res = await fetch(`/api/products?limit=8&page=1&sort=random`);
if (res.ok) {
const data = await res.json();
setProducts(data.products.filter((p: Product) => p.id !== currentProductId).slice(0, 8));
}
} catch (err) {
console.error(err);
}
};
fetchSimilar();
}, [currentProductId]);
if (products.length === 0) return null;
return (
<div className="similar-products">
<h2>Liknande produkter</h2>
{/* Horizontal Scroll Layout - Centered if possible */}
<HorizontalCarousel>
{products.map(product => (
<div key={product.id} className="flex-shrink-0 w-64 md:w-72 lg:w-80 snap-start">
<ProductCard product={product} />
</div>
))}
</HorizontalCarousel>
</div>
);
}

13
src/app/products/page.tsx Normal file
View File

@ -0,0 +1,13 @@
"use client";
import { Suspense } from 'react';
import { Loader2 } from 'lucide-react';
import ProductCatalog from '@/components/products/ProductCatalog';
export default function ProductsPage() {
return (
<Suspense fallback={<div className="p-10 text-center"><Loader2 className="animate-spin mx-auto" /></div>}>
<ProductCatalog />
</Suspense>
);
}

439
src/app/register/page.tsx Normal file
View File

@ -0,0 +1,439 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuth } from '@/context/AuthContext';
import { validatePersonnummer, lookupSwedishCity, validateAddress, lookupSwedishAddress, validateSwedishMobile, normalizeSwedishMobile } from '@/lib/validation';
import { FcGoogle } from 'react-icons/fc';
import '@/styles/Auth.css';
export default function RegisterPage() {
const router = useRouter();
const { login, isAuthenticated, loading: authLoading } = useAuth();
const [formData, setFormData] = useState({
name: '',
username: '',
email: '',
password: '',
confirmPassword: '',
personnummer: '',
mobile: '46',
address: '',
zip_code: '',
city: '',
country: 'Sverige',
newsletter: false,
terms: false,
});
const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [loading, setLoading] = useState(false);
const [showTermsModal, setShowTermsModal] = useState(false);
const processingGoogleAuth = useRef(false);
useEffect(() => {
if (!authLoading && isAuthenticated) {
router.push('/me');
return;
}
if (processingGoogleAuth.current) return;
// Handle successful Google Redirect
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
const userData = params.get('user');
if (token && userData) {
processingGoogleAuth.current = true;
try {
login(token, JSON.parse(userData), true);
window.history.replaceState({}, document.title, window.location.pathname);
router.push('/me');
} catch (e) {
console.error("Failed to parse Google user data", e);
processingGoogleAuth.current = false;
}
}
}, [isAuthenticated, authLoading, router, login]);
// Auto-fetch city when zip code is 5 digits
useEffect(() => {
const fetchCity = async () => {
if (formData.zip_code.length === 5) {
const city = await lookupSwedishCity(formData.zip_code);
if (city) {
setFormData(prev => ({ ...prev, city }));
}
}
};
fetchCity();
}, [formData.zip_code]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Lösenorden matchar inte');
return;
}
// Swedish Validations
if (!validatePersonnummer(formData.personnummer)) {
setError('Personnummer är ogiltigt. Kontrollera att datumet är korrekt och att formatet är ÅÅÅÅMMDD-XXXX.');
return;
}
if (!validateAddress(formData.address)) {
setError('Adressen måste innehålla både gatunamn och nummer.');
return;
}
// Verify physical address exists (External API)
try {
const addressExists = await lookupSwedishAddress(formData.address, formData.zip_code, formData.city);
if (!addressExists) {
setError('Adressen verkar inte existera. Vänligen kontrollera stavning och adressnummer.');
setLoading(false);
return;
}
} catch (err) {
console.error('Frontend address verification error:', err);
// Fail open on network errors to avoid blocking users
}
if (!validateSwedishMobile(formData.mobile)) {
setError('Mobilnummeret är ogiltigt. Det måste vara ett svenskt mobilnummer (t.ex. 070-123 45 67).');
return;
}
const normalizedMobile = normalizeSwedishMobile(formData.mobile);
const zipRegex = /^\d{5}$/;
if (!zipRegex.test(formData.zip_code)) {
setError('Postnummer måste bestå av 5 siffror');
return;
}
if (!formData.terms) {
setError('Du måste godkänna användarvillkoren');
return;
}
setLoading(true);
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: formData.name,
username: formData.username,
email: formData.email,
password: formData.password,
newsletter: formData.newsletter,
personnummer: formData.personnummer,
mobile: normalizedMobile,
address: formData.address,
zip_code: formData.zip_code,
city: formData.city,
country: formData.country,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Registreringen misslyckades');
}
// Don't auto-login if account needs verification
setSuccessMessage(data.message || 'Kontot har skapats! Kontrollera din e-post för att verifiera din adress.');
setLoading(false);
// Optional: Redirect to login after a few seconds or just show the message
setTimeout(() => {
router.push('/login');
}, 5000);
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Ett oväntat fel uppstod');
}
setLoading(false);
}
};
const handleGoogleLogin = async () => {
try {
const res = await fetch('/api/auth/google/url');
const data = await res.json();
if (data.url) {
window.location.href = data.url;
} else {
setError('Kunde inte starta Google-inloggning');
}
} catch {
setError('Ett fel uppstod vid Google-inloggning');
}
};
if (authLoading || isAuthenticated) {
return null;
}
return (
<div className="auth-page-container">
<div className="profile-card card-md">
<h2 className="auth-title">Skapa konto</h2>
{successMessage && (
<div className="auth-success">
{successMessage}
</div>
)}
<p className="auth-subtitle">
Fyll i dina uppgifter nedan för att bli medlem
</p>
<form className="auth-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Fullständigt namn</label>
<input
type="text"
required
className="form-input"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Användarnamn</label>
<input
type="text"
required
className="form-input"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">E-postadress</label>
<input
type="email"
required
className="form-input"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Lösenord</label>
<input
type="password"
required
className="form-input"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Bekräfta lösenord</label>
<input
type="password"
required
className="form-input"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
/>
</div>
<div className="profile-form-row">
<div className="form-group">
<label className="form-label">Personnummer</label>
<input
type="text"
required
className="form-input"
placeholder="ÅÅÅÅMMDD-XXXX"
maxLength={13}
value={formData.personnummer}
onChange={(e) => {
let val = e.target.value.replace(/\D/g, '');
if (val.length > 8) {
val = val.slice(0, 8) + '-' + val.slice(8, 12);
}
setFormData({ ...formData, personnummer: val });
}}
/>
</div>
<div className="form-group">
<label className="form-label">Mobilnummer</label>
<input
type="text"
required
className="form-input"
placeholder="070-123 45 67"
value={formData.mobile}
onChange={(e) => setFormData({ ...formData, mobile: e.target.value })}
/>
</div>
</div>
<div className="form-group">
<label className="form-label">Adress (Gata & Nummer)</label>
<input
type="text"
required
className="form-input"
placeholder="Ex: Kungsgatan 1"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
/>
</div>
<div className="profile-form-row">
<div className="form-group">
<label className="form-label">Postnummer</label>
<input
type="text"
required
className="form-input"
placeholder="12345"
maxLength={5}
value={formData.zip_code}
onChange={(e) => setFormData({ ...formData, zip_code: e.target.value.replace(/\D/g, '') })}
/>
</div>
<div className="form-group">
<label className="form-label">Ort</label>
<input
type="text"
required
className="form-input"
placeholder="Ex. Solna"
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
/>
</div>
</div>
<div className="form-group">
<label className="form-label">Land</label>
<select
className="form-input form-select-raw"
value={formData.country}
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
>
<option value="Sverige">Sverige</option>
</select>
</div>
<div className="form-checkbox-group" onClick={() => setFormData({ ...formData, newsletter: !formData.newsletter })}>
<input
type="checkbox"
className="form-checkbox"
checked={formData.newsletter}
onChange={() => { }} // Handled by group click
/>
<span>Jag vill nyheter och uppdateringar</span>
</div>
<div className="form-checkbox-group">
<input
type="checkbox"
className="form-checkbox"
checked={formData.terms}
onChange={(e) => setFormData({ ...formData, terms: e.target.checked })}
/>
<span>
Jag godkänner{' '}
<button
type="button"
className="auth-link btn-link-raw"
onClick={() => setShowTermsModal(true)}
>
användarvillkoren
</button>
</span>
</div>
{error && (
<div className="auth-error">
{error}
</div>
)}
<div className="auth-button-container mt-8">
<button
type="submit"
disabled={loading}
className="auth-button"
>
{loading ? 'Skapar konto...' : 'Skapa konto'}
</button>
<button
type="button"
onClick={handleGoogleLogin}
className="auth-button google"
>
<FcGoogle size={20} />
Fortsätt med Google
</button>
<div className="auth-divider">ELLER</div>
<Link href="/login" className="auth-button secondary">
Logga in
</Link>
</div>
</form>
</div>
{showTermsModal && (
<div className="modal-overlay" onClick={() => setShowTermsModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setShowTermsModal(false)}>&times;</button>
<h3 className="modal-title">Användarvillkor</h3>
<div className="modal-body">
<p>Välkommen till Nordic Storium. Genom att använda vår tjänst godkänner du följande villkor:</p>
<h4>1. Allmänt</h4>
<p>Nordic Storium tillhandahåller premium skandinaviska möbler. Vi förbehåller oss rätten att ändra villkoren när som helst.</p>
<h4>2. Integritetspolicy</h4>
<p>Vi hanterar dina personuppgifter med största försiktighet i enlighet med GDPR. Vi säljer aldrig dina uppgifter till tredje part.</p>
<h4>3. Beställningar</h4>
<p>Alla beställningar är bindande. Vi förbehåller oss rätten att annullera ordrar vid felaktig lagerstatus eller prisinformation.</p>
<h4>4. Leverans</h4>
<p>Vi strävar efter att leverera dina varor snabbt som möjligt, normalt inom 3-5 arbetsdagar i Sverige.</p>
<button
className="auth-button"
onClick={() => {
setFormData({ ...formData, terms: true });
setShowTermsModal(false);
}}
>
Jag förstår och godkänner
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,113 @@
"use client";
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { use } from 'react';
import '@/styles/Auth.css';
export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = use(params);
const router = useRouter();
const [formData, setFormData] = useState({
password: '',
confirmPassword: '',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Lösenorden matchar inte');
return;
}
setLoading(true);
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
password: formData.password,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Något gick fel');
setSuccess(true);
setTimeout(() => {
router.push('/login');
}, 3000);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ett oväntat fel uppstod');
} finally {
setLoading(false);
}
};
return (
<div className="auth-page-container">
<div className="profile-card card-sm">
<h2 className="auth-title">VÄLJ NYTT LÖSENORD</h2>
<p className="auth-subtitle">
Ange ditt nya önskade lösenord nedan.
</p>
{success ? (
<div className="text-center">
<div className="auth-success">
Ditt lösenord har ändrats! Omdirigerar till inloggning...
</div>
</div>
) : (
<form className="auth-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Nytt Lösenord</label>
<input
id="password"
type="password"
required
className="form-input"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Bekräfta Lösenord</label>
<input
id="confirmPassword"
type="password"
required
className="form-input"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
/>
</div>
{error && (
<div className="auth-error">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="auth-button"
>
{loading ? 'Uppdaterar...' : 'Spara Lösenord'}
</button>
</form>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,372 @@
"use client";
import { useState, useRef } from 'react';
import Image from 'next/image';
import { X, Upload } from 'lucide-react';
import '@/styles/Auth.css';
export default function SaljaMobler() {
const [formData, setFormData] = useState({
name: '',
address: '',
email: '',
phone: '',
message: '',
});
const [images, setImages] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<'success' | 'error' | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value
}));
};
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const newFiles = Array.from(files);
const totalImages = images.length + newFiles.length;
if (totalImages > 4) {
setErrorMessage('Max 4 bilder tillåtna');
setTimeout(() => setErrorMessage(''), 3000);
return;
}
// Validate file sizes
for (const file of newFiles) {
if (file.size > 5 * 1024 * 1024) {
setErrorMessage('Bilderna får max vara 5MB vardera');
setTimeout(() => setErrorMessage(''), 3000);
return;
}
if (!file.type.startsWith('image/')) {
setErrorMessage('Endast bildfiler tillåtna');
setTimeout(() => setErrorMessage(''), 3000);
return;
}
}
setImages(prev => [...prev, ...newFiles]);
// Create previews
newFiles.forEach(file => {
const reader = new FileReader();
reader.onloadend = () => {
setImagePreviews(prev => [...prev, reader.result as string]);
};
reader.readAsDataURL(file);
});
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const removeImage = (index: number) => {
setImages(prev => prev.filter((_, i) => i !== index));
setImagePreviews(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setSubmitStatus(null);
setErrorMessage('');
try {
const formPayload = new FormData();
formPayload.append('name', formData.name);
formPayload.append('address', formData.address);
formPayload.append('email', formData.email);
formPayload.append('phone', formData.phone);
formPayload.append('message', formData.message);
images.forEach(image => {
formPayload.append('images', image);
});
const res = await fetch('/api/sell-furniture', {
method: 'POST',
body: formPayload,
});
const data = await res.json();
if (res.ok) {
setSubmitStatus('success');
setFormData({ name: '', address: '', email: '', phone: '', message: '' });
setImages([]);
setImagePreviews([]);
} else {
setSubmitStatus('error');
setErrorMessage(data.error || 'Något gick fel. Försök igen.');
}
} catch (err) {
console.error('Sell furniture form error:', err);
setSubmitStatus('error');
setErrorMessage('Kunde inte skicka meddelandet. Försök igen senare.');
} finally {
setIsSubmitting(false);
setTimeout(() => {
setSubmitStatus(null);
setErrorMessage('');
}, 5000);
}
};
return (
<div className="auth-page-container">
<div className="card-xl" style={{ width: '100%', maxWidth: '1200px' }}>
{/* Hero Section */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr',
gap: '3rem',
alignItems: 'center',
marginBottom: '4rem',
paddingBottom: '3rem',
borderBottom: '1px solid var(--border-color)'
}} className="hero-grid">
<div style={{ position: 'relative', width: '100%', height: '300px', borderRadius: '8px', overflow: 'hidden' }}>
<Image
src="/artifacts/sell_furniture_hero.webp"
alt="Sälja Möbler"
fill
style={{ objectFit: 'cover' }}
priority
/>
</div>
<div>
<h1 className="auth-title" style={{ textAlign: 'left', marginBottom: '1.5rem', fontSize: '2.5rem' }}>
Sälja Dina Kontorsmöbler
</h1>
<div style={{ fontSize: '0.95rem', lineHeight: '1.8', color: 'var(--text-color)' }}>
<p style={{ marginBottom: '1.5rem' }}>
Ge dina begagnade kontorsmöbler ett nytt liv! Istället för att kasta möblerna kan du sälja dem till oss.
</p>
<div style={{
background: 'var(--card-bg)',
padding: '1.5rem',
border: '2px solid var(--border-color)',
marginBottom: '1.5rem'
}}>
<p style={{ fontSize: '0.85rem', fontWeight: '900', textTransform: 'uppercase', letterSpacing: '1px', marginBottom: '1rem' }}>
Vi tar hand om hela processen:
</p>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
<li style={{ marginBottom: '0.75rem', paddingLeft: '1.5rem', position: 'relative' }}>
<span style={{ position: 'absolute', left: 0 }}></span>
Värdering och köp av dina möbler
</li>
<li style={{ marginBottom: '0.75rem', paddingLeft: '1.5rem', position: 'relative' }}>
<span style={{ position: 'absolute', left: 0 }}></span>
Hjälp med inköp av nya möbler
</li>
<li style={{ paddingLeft: '1.5rem', position: 'relative' }}>
<span style={{ position: 'absolute', left: 0 }}></span>
Hållbar och miljövänlig lösning
</li>
</ul>
</div>
<p style={{ fontSize: '0.9rem', fontWeight: '600' }}>
Skicka in information och bilder dina möbler nedan, hör vi av oss!
</p>
</div>
</div>
</div>
{/* Form Card */}
<div className="profile-card">
<h3 className="auth-subtitle" style={{ marginBottom: '2rem' }}>Skicka Ditt Förslag</h3>
<form onSubmit={handleSubmit}>
<div className="form-group" style={{ position: 'relative', marginBottom: '1.5rem' }}>
<input
type="text"
name="name"
placeholder="Namn"
className="form-input"
value={formData.name}
onChange={handleChange}
required
/>
</div>
<div className="form-group" style={{ position: 'relative', marginBottom: '1.5rem' }}>
<input
type="text"
name="address"
placeholder="Adress"
className="form-input"
value={formData.address}
onChange={handleChange}
required
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1.5rem' }} className="form-row">
<div className="form-group" style={{ position: 'relative' }}>
<input
type="email"
name="email"
placeholder="E-post"
className="form-input"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group" style={{ position: 'relative' }}>
<input
type="tel"
name="phone"
placeholder="Telefonnummer"
className="form-input"
value={formData.phone}
onChange={handleChange}
required
/>
</div>
</div>
<div className="form-group" style={{ position: 'relative', marginBottom: '2rem' }}>
<textarea
name="message"
placeholder="Meddelande (beskriv möblerna, tillstånd, etc.)"
className="form-input"
value={formData.message}
onChange={handleChange}
required
rows={6}
style={{ resize: 'vertical', minHeight: '180px', paddingTop: '1.2rem' }}
/>
</div>
{/* Image Upload Section */}
<div style={{ marginBottom: '2rem' }}>
<p style={{
fontSize: '0.85rem',
fontWeight: '900',
textTransform: 'uppercase',
letterSpacing: '1px',
marginBottom: '1rem',
color: 'var(--text-secondary)'
}}>
Bilder ({images.length}/4)
</p>
{imagePreviews.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gap: '1rem',
marginBottom: '1rem'
}}>
{imagePreviews.map((preview, index) => (
<div key={index} style={{ position: 'relative', aspectRatio: '1', border: '2px solid var(--border-color)', borderRadius: '4px', overflow: 'hidden' }}>
<img
src={preview}
alt={`Preview ${index + 1}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<button
type="button"
onClick={() => removeImage(index)}
style={{
position: 'absolute',
top: '0.5rem',
right: '0.5rem',
background: 'rgba(0,0,0,0.7)',
border: 'none',
borderRadius: '50%',
width: '28px',
height: '28px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'white'
}}
>
<X size={16} />
</button>
</div>
))}
</div>
)}
{images.length < 4 && (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleImageSelect}
style={{ display: 'none' }}
id="image-upload"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="auth-button secondary"
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem' }}
>
<Upload size={20} />
{images.length === 0 ? 'Ladda upp bilder' : 'Ladda upp fler bilder'}
</button>
</>
)}
</div>
<button type="submit" className="auth-button primary" disabled={isSubmitting} style={{ width: '100%' }}>
{isSubmitting ? 'Skickar...' : 'Skicka Förfrågan'}
</button>
{submitStatus === 'success' && (
<div className="auth-success" style={{ marginTop: '1.5rem' }}>
Tack för ditt meddelande! Vi återkommer snart vi kan.
</div>
)}
{(submitStatus === 'error' || errorMessage) && (
<div className="auth-error" style={{ marginTop: '1.5rem' }}>
{errorMessage || 'Något gick fel. Försök igen.'}
</div>
)}
</form>
</div>
</div>
<style jsx>{`
@media (min-width: 900px) {
.hero-grid {
grid-template-columns: 450px 1fr !important;
}
.form-row {
grid-template-columns: 1fr 1fr !important;
}
}
@media (max-width: 899px) {
.hero-grid {
grid-template-columns: 1fr !important;
}
.form-row {
grid-template-columns: 1fr !important;
}
}
`}</style>
</div>
);
}

115
src/app/villkor/page.tsx Normal file
View File

@ -0,0 +1,115 @@
import Link from 'next/link';
export default function Villkor() {
return (
<div className="container mx-auto px-4 py-12">
<div className="max-w-4xl mx-auto p-8 rounded-2xl border border-[var(--border-color)] bg-[var(--card-bg)] shadow-lg">
<header className="text-center mb-12 pb-8 border-b border-[var(--border-color)]">
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-[var(--text-color)] font-['Montserrat']">Köpvillkor</h1>
<p className="text-[var(--text-secondary)]">Senast uppdaterad: {new Date().toLocaleDateString('sv-SE')}</p>
</header>
<div className="space-y-10 text-[var(--text-color)]">
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">Priser och betalning</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Alla priser anges inklusive 25% moms. Det totala priset inkluderar alla avgifter.
</p>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Vi erbjuder trygg och enkel betalning via kort eller Swish.
</p>
</section>
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">Beställning</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Möjligheten att lägga beställningar direkt via webbshoppen lanseras inom kort.
Just nu tar vi emot beställningar manuellt via telefon <a href="tel:0700342324" className="text-blue-600 hover:underline">070-034 23 24</a>,
mejl <a href="mailto:info@nordicstorium.se" className="text-blue-600 hover:underline">info@nordicstorium.se</a> eller via vår chatt här hemsidan.
</p>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Har du frågor om specifika produkter eller vill ha en offert? Tveka inte att kontakta oss!
</p>
</section>
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">Hämta varorna själv</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Varorna kan hämtas vårt lager eller i butiken. Vi kontaktar dig när dina varor finns klara för upphämtning. Legitimering krävs.
</p>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Det går även bra att komma till oss och titta varorna före beställning.
Ring gärna innan ni kommer om ni vill se en specifik vara, kan vi förbereda den.
</p>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)] text-center">
<strong className="text-[var(--text-color)]">Skarprättarvägen 30 i Järfälla - Öppet alla dagar 09:00 - 22:00</strong>
</p>
</section>
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">Transport och Leverans</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
<strong className="text-[var(--text-color)]">Fri frakt inom Stockholm!</strong> Vi erbjuder kostnadsfri hemleverans till adresser i Stockholm och närliggande områden.
</p>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Leverans sker med våra egna bilar. Avlastning sker normalt vid tomtgräns eller port.
Inbärning och montering ingår ej men kan ofta ordnas mot en extra kostnad kontakta oss för prisuppgift.
</p>
</section>
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">Leverans utanför Stockholm</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
För leveranser utanför Stockholmsområdet anlitar vi fraktbolag (vanligtvis Postnord).
tillkommer fraktkostnad enligt gällande taxa.
</p>
<ul className="list-disc pl-5 space-y-2 text-[var(--text-secondary)]">
<li>Mottagaren måste vara anträffbar under normal arbetstid för leverans.</li>
<li>Vid retur grund av felaktiga uppgifter eller ej mottagen vara debiteras faktiska fraktkostnader.</li>
<li>Kontrollera alltid godset vid mottagandet. Synliga skador ska anmälas direkt till chauffören.</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">Reservationer</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
vi säljer begagnade varor eller konkurslager reserverar vi oss för slutförsäljning.
Produkternas skick och färgnyanser kan ibland skilja sig något från bilderna, även om vi alltid försöker beskriva varorna korrekt som möjligt.
Vid tveksamheter är ni alltid välkomna att inspektera varan plats.
</p>
</section>
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">Ångerrätt & Öppet köp</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Som privatkund har du enligt distansavtalslagen 14 dagars ångerrätt vid köp distans.
Vid retur står du som kund för fraktkostnaden (t.o.r) och varan ska återlämnas i samma skick som den levererades.
</p>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">Ångerrätten gäller ej för företagskunder eller kundanpassade beställningsvaror.</p>
</section>
<section>
<h2 className="text-2xl font-bold mb-4 text-[var(--accent-color)] font-['Montserrat']">Garantier & Reklamation</h2>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Vi lämnar 6 månaders garanti begagnade varor. Detta täcker funktionsfel men ej normalt slitage eller skönhetsfel som fanns vid köpet.
Elektriska produkter (t.ex. höj- & sänkbara skrivbord) funktionestestas alltid innan leverans.
</p>
<p className="mb-4 leading-relaxed text-[var(--text-secondary)]">
Vid problem, kontakta oss omgående hjälper vi dig!
</p>
</section>
</div>
<footer className="mt-12 pt-8 border-t border-[var(--border-color)] text-center">
<Link href="/" className="inline-block px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors duration-300">
Tillbaka till startsidan
</Link>
</footer>
</div>
</div>
);
}

BIN
src/assets/logo-main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Some files were not shown because too many files have changed in this diff Show More