First commit
|
|
@ -0,0 +1,11 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
*.md
|
||||
.env
|
||||
.env.local
|
||||
docker-compose.override.yml
|
||||
mysql-data/
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
?>
|
||||
|
|
@ -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:
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
);
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 644 KiB |
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 724 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1,3 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: https://www.nordicstorium.se/sitemap.xml
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
|
@ -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';
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 "=================================================="
|
||||
|
|
@ -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 på 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 på startsidan</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Max 8 kategorier kan visas på 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 på 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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 så 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">
|
||||
Gå tillbaka
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 på 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 på 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 så 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 så 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 få 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 ("rätten att bli bortglömd"), förutom då lagkrav kräver att vi sparar uppgifterna.</li>
|
||||
<li><strong>Rätt till dataportabilitet:</strong> Rätt att få ut dina uppgifter i ett strukturerat format.</li>
|
||||
</ul>
|
||||
<p className="mt-4 leading-relaxed text-[var(--text-secondary)]">
|
||||
Kontakta oss på <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 >
|
||||
);
|
||||
}
|
||||
|
|
@ -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 så återkopplar vi så 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 så 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">Få 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 få 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 på 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, så 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 på 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 →
|
||||
</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 →
|
||||
</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 →
|
||||
</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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 få 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)}>×</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 så 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 på dina möbler nedan, så 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 så 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 på 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 på 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 på varorna före beställning.
|
||||
Ring gärna innan ni kommer om ni vill se en specifik vara, så 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).
|
||||
Då 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 på 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)]">
|
||||
Då 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 så korrekt som möjligt.
|
||||
Vid tveksamheter är ni alltid välkomna att inspektera varan på 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 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 på 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 så 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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 177 KiB |