Commit iniziale
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
APP_URL=http://localhost
|
||||
DATABASE_URL=postgresql://ecommerce:ecommerce_password@db:5432/ecommerce
|
||||
AUTH_SECRET=dev-secret-change-in-production-32chars
|
||||
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||
INITIAL_ADMIN_PASSWORD=Admin1234!test
|
||||
STRIPE_SECRET_KEY=sk_test_placeholder
|
||||
STRIPE_WEBHOOK_SECRET=whsec_placeholder
|
||||
SMTP_HOST=mailpit
|
||||
SMTP_PORT=1025
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@localhost
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Node
|
||||
app/node_modules/
|
||||
node_modules/
|
||||
|
||||
# Next.js build output
|
||||
app/.next/
|
||||
app/out/
|
||||
|
||||
# Prisma generated client (rebuilt on npm install)
|
||||
app/node_modules/.prisma/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.vscode/settings.json
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Docker volumes (dati locali)
|
||||
pgdata/
|
||||
caddy_data/
|
||||
caddy_config/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Uploads / media locali
|
||||
app/public/uploads/
|
||||
@@ -0,0 +1,334 @@
|
||||
# E-commerce Platform
|
||||
|
||||
Piattaforma e-commerce containerizzata, avviabile con un singolo comando.
|
||||
|
||||
**Stack:** Next.js 14 · PostgreSQL 16 · Prisma · Stripe · Caddy · Docker Compose
|
||||
|
||||
---
|
||||
|
||||
## Avvio in locale (test su localhost)
|
||||
|
||||
### Prerequisiti
|
||||
|
||||
- [Docker Desktop](https://docs.docker.com/get-docker/) installato e avviato
|
||||
- Porta **80** libera (nessun altro web server in esecuzione)
|
||||
|
||||
### 1. Clona il repository
|
||||
|
||||
```bash
|
||||
git clone <url-repository>
|
||||
cd ecommerce-platform
|
||||
```
|
||||
|
||||
### 2. Crea il file `.env`
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Il file `.env` di default è già configurato per localhost. **Non serve modificare nulla** per i primi test.
|
||||
|
||||
### 3. Avvia la piattaforma
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Il primo avvio richiede **5–10 minuti**: Docker scarica le immagini, installa le dipendenze npm, compila Next.js, esegue le migrazioni e crea l'utente admin.
|
||||
|
||||
Segui il progresso con:
|
||||
|
||||
```bash
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
La piattaforma è pronta quando vedi:
|
||||
|
||||
```
|
||||
✓ Ready in 91ms
|
||||
```
|
||||
|
||||
### 4. Apri nel browser
|
||||
|
||||
| URL | Cosa trovi |
|
||||
|-----|------------|
|
||||
| http://localhost | Sito pubblico (vetrina) |
|
||||
| http://localhost/admin | Dashboard amministratore |
|
||||
| http://localhost:8025 | Mailpit — cattura le email di test |
|
||||
|
||||
### 5. Primo accesso admin
|
||||
|
||||
Vai su **http://localhost/admin** e accedi con:
|
||||
|
||||
```
|
||||
Email: admin@example.com
|
||||
Password: Admin1234!test
|
||||
```
|
||||
|
||||
Al primo accesso il sistema ti obbliga a cambiare la password.
|
||||
|
||||
**Requisiti password:** minimo 12 caratteri, almeno una maiuscola, una minuscola, un numero e un simbolo.
|
||||
|
||||
### 6. Configura il negozio (ordine consigliato)
|
||||
|
||||
1. **Product Types** → crea un tipo di prodotto (es. "Prodotto") con gli attributi che vuoi
|
||||
2. **Categories** → crea le categorie
|
||||
3. **Products** → crea un prodotto, assegna tipo e categoria, impostalo su **Published**
|
||||
4. Apri http://localhost → il prodotto appare in homepage
|
||||
|
||||
### 7. Cosa funziona in locale senza configurazione extra
|
||||
|
||||
| Funzionalità | Stato |
|
||||
|---|---|
|
||||
| Admin dashboard completa | ✅ |
|
||||
| Gestione prodotti, categorie, ordini | ✅ |
|
||||
| Registrazione e login clienti | ✅ |
|
||||
| Email (reset password, ecc.) | ✅ visibili su http://localhost:8025 |
|
||||
| Pagamenti Stripe | ⚠️ richiede chiavi reali (vedi sotto) |
|
||||
|
||||
**Per testare i pagamenti Stripe** in locale, inserisci una chiave `sk_test_...` reale nel `.env` (gratuita, modalità test su [dashboard.stripe.com](https://dashboard.stripe.com/test/apikeys)):
|
||||
|
||||
```env
|
||||
STRIPE_SECRET_KEY=sk_test_la_tua_chiave
|
||||
```
|
||||
|
||||
Poi riavvia l'app:
|
||||
|
||||
```bash
|
||||
docker compose restart app
|
||||
```
|
||||
|
||||
### 8. Ferma la piattaforma
|
||||
|
||||
```bash
|
||||
docker compose down # ferma i container, i dati restano
|
||||
docker compose down -v # ferma e cancella anche il database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deploy in produzione
|
||||
|
||||
### Prerequisiti
|
||||
|
||||
- Server Linux con Docker e Docker Compose v2 (qualsiasi VPS o cloud VM)
|
||||
- Dominio con record **A** puntato all'IP del server
|
||||
- Porte **80** e **443** aperte nel firewall del server
|
||||
- Account [Stripe](https://stripe.com) (per i pagamenti reali)
|
||||
- Account SMTP (per le email: Mailgun, Resend, SendGrid, ecc.)
|
||||
|
||||
### 1. Clona il repository sul server
|
||||
|
||||
```bash
|
||||
git clone <url-repository>
|
||||
cd ecommerce-platform
|
||||
```
|
||||
|
||||
### 2. Crea e configura `.env`
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Modifica questi valori:
|
||||
|
||||
```env
|
||||
# URL pubblico del sito — con https://
|
||||
APP_URL=https://tuodominio.com
|
||||
|
||||
# Genera un segreto casuale: openssl rand -hex 32
|
||||
AUTH_SECRET=incolla_qui_il_risultato_di_openssl
|
||||
|
||||
# Credenziali del primo admin — cambia subito dopo il primo accesso
|
||||
INITIAL_ADMIN_EMAIL=admin@tuodominio.com
|
||||
INITIAL_ADMIN_PASSWORD=UnaPasswordSicura1!
|
||||
|
||||
# Stripe — usa le chiavi live (non sk_test_)
|
||||
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# SMTP reale per le email
|
||||
SMTP_HOST=smtp.tuoprovider.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=user@tuodominio.com
|
||||
SMTP_PASSWORD=la_tua_password_smtp
|
||||
SMTP_FROM=noreply@tuodominio.com
|
||||
```
|
||||
|
||||
> `DATABASE_URL` non va modificato: usa già il nome del container Docker corretto.
|
||||
|
||||
### 3. Genera `AUTH_SECRET`
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Copia l'output nel campo `AUTH_SECRET` nel file `.env`.
|
||||
|
||||
### 4. Configura il dominio nel Caddyfile
|
||||
|
||||
Apri `Caddyfile` e sostituisci `localhost` con il tuo dominio:
|
||||
|
||||
```
|
||||
tuodominio.com {
|
||||
encode gzip zstd
|
||||
reverse_proxy app:3000
|
||||
}
|
||||
```
|
||||
|
||||
Caddy ottiene e rinnova automaticamente il certificato HTTPS tramite Let's Encrypt. Non serve nessuna configurazione SSL manuale.
|
||||
|
||||
### 5. Avvia
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Attendi che l'app sia pronta:
|
||||
|
||||
```bash
|
||||
docker compose logs -f app
|
||||
# attendi: ✓ Ready in ...ms
|
||||
```
|
||||
|
||||
Il sito è live su `https://tuodominio.com`.
|
||||
|
||||
### 6. Configura il webhook Stripe
|
||||
|
||||
Nel [dashboard Stripe → Webhooks](https://dashboard.stripe.com/webhooks), aggiungi un endpoint:
|
||||
|
||||
```
|
||||
URL endpoint: https://tuodominio.com/api/webhooks/stripe
|
||||
|
||||
Eventi da ascoltare:
|
||||
- checkout.session.completed
|
||||
- payment_intent.succeeded
|
||||
- payment_intent.payment_failed
|
||||
```
|
||||
|
||||
Copia il **Signing secret** (`whsec_...`) nel `.env` come `STRIPE_WEBHOOK_SECRET`, poi:
|
||||
|
||||
```bash
|
||||
docker compose restart app
|
||||
```
|
||||
|
||||
### 7. Primo accesso e configurazione negozio
|
||||
|
||||
1. Vai su `https://tuodominio.com/admin`
|
||||
2. Accedi con le credenziali del `.env`
|
||||
3. Cambia la password quando richiesto
|
||||
4. Configura il negozio in questo ordine:
|
||||
- **Settings** → nome negozio, logo, colori
|
||||
- **Product Types** → tipi di prodotto con attributi personalizzati
|
||||
- **Categories** → categorie prodotto
|
||||
- **Products** → aggiungi i tuoi prodotti
|
||||
- **Admin Users** → aggiungi altri amministratori se necessario
|
||||
|
||||
---
|
||||
|
||||
## Aggiornamenti
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose build app
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Le migrazioni del database vengono applicate automaticamente all'avvio.
|
||||
|
||||
---
|
||||
|
||||
## Backup database
|
||||
|
||||
**Dump manuale:**
|
||||
|
||||
```bash
|
||||
docker compose exec db pg_dump -U ecommerce ecommerce > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
```
|
||||
|
||||
**Ripristino:**
|
||||
|
||||
```bash
|
||||
cat backup_XXXXXXXX_XXXXXX.sql | docker compose exec -T db psql -U ecommerce ecommerce
|
||||
```
|
||||
|
||||
**Backup automatico giornaliero** (aggiungi al crontab del server con `crontab -e`):
|
||||
|
||||
```bash
|
||||
0 3 * * * cd /percorso/ecommerce-platform && docker compose exec -T db pg_dump -U ecommerce ecommerce | gzip > /backups/ecommerce_$(date +\%Y\%m\%d).sql.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Problemi comuni
|
||||
|
||||
**Il build impiega molto tempo al primo avvio**
|
||||
È normale. npm install + compilazione Next.js richiedono 5–10 minuti la prima volta. I build successivi sono molto più veloci grazie alla cache Docker.
|
||||
|
||||
**http://localhost non risponde (502 Bad Gateway)**
|
||||
L'app è ancora in fase di avvio. Aspetta qualche secondo e ricarica. Controlla lo stato con:
|
||||
```bash
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
**Errore "port 80 already in use"**
|
||||
Un altro servizio usa la porta 80 (Apache, Nginx, ecc.). Fermalo o cambia la porta nel `docker-compose.yml`.
|
||||
|
||||
**Dimentico la password admin**
|
||||
Resetta direttamente nel database:
|
||||
```bash
|
||||
# genera hash bcrypt per la nuova password
|
||||
docker compose exec app node -e "
|
||||
const bcrypt = require('bcryptjs');
|
||||
bcrypt.hash('NuovaPassword1!', 12).then(h => console.log(h));
|
||||
"
|
||||
# aggiorna nel db
|
||||
docker compose exec db psql -U ecommerce ecommerce -c \
|
||||
"UPDATE \"User\" SET \"passwordHash\"='HASH_QUI', \"mustChangePassword\"=true WHERE email='admin@example.com';"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Struttura del progetto
|
||||
|
||||
```
|
||||
ecommerce-platform/
|
||||
├── docker-compose.yml Orchestrazione: db, app, caddy, mailpit
|
||||
├── Caddyfile Reverse proxy — modifica qui il dominio
|
||||
├── .env Variabili d'ambiente (non committare)
|
||||
├── .env.example Template da copiare
|
||||
└── app/
|
||||
├── Dockerfile Build multi-stage Next.js
|
||||
├── entrypoint.sh Sequenza avvio: migrazioni → admin → server
|
||||
├── prisma/
|
||||
│ └── schema.prisma Schema database completo
|
||||
├── scripts/
|
||||
│ └── bootstrap-admin.ts Crea il primo OWNER se non esiste
|
||||
└── src/
|
||||
├── app/ Pagine Next.js (App Router)
|
||||
│ ├── admin/ Dashboard admin (protetta per ruolo)
|
||||
│ ├── api/ API routes (auth, prodotti, ordini, webhook)
|
||||
│ └── (storefront) Homepage, catalogo, carrello, checkout
|
||||
├── components/ Componenti React riutilizzabili
|
||||
└── lib/ Auth, Prisma client, Stripe, email
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variabili d'ambiente
|
||||
|
||||
| Variabile | Descrizione | Locale | Produzione |
|
||||
|-----------|-------------|:------:|:----------:|
|
||||
| `APP_URL` | URL pubblico del sito | `http://localhost` | `https://tuodominio.com` |
|
||||
| `DATABASE_URL` | Connessione PostgreSQL | invariato | invariato |
|
||||
| `AUTH_SECRET` | Segreto sessioni (min 32 char) | qualsiasi | `openssl rand -hex 32` |
|
||||
| `INITIAL_ADMIN_EMAIL` | Email primo admin | qualsiasi | la tua email |
|
||||
| `INITIAL_ADMIN_PASSWORD` | Password primo admin | qualsiasi | sicura |
|
||||
| `STRIPE_SECRET_KEY` | Chiave Stripe | `sk_test_...` | `sk_live_...` |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Segreto webhook Stripe | opzionale | obbligatorio |
|
||||
| `SMTP_HOST` | Server SMTP | `mailpit` | provider reale |
|
||||
| `SMTP_PORT` | Porta SMTP | `1025` | `587` |
|
||||
| `SMTP_USER` | Utente SMTP | vuoto | obbligatorio |
|
||||
| `SMTP_PASSWORD` | Password SMTP | vuoto | obbligatorio |
|
||||
| `SMTP_FROM` | Mittente email | qualsiasi | `noreply@tuodominio.com` |
|
||||
@@ -0,0 +1,36 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache openssl
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
|
||||
COPY --from=builder /app/node_modules/bcryptjs ./node_modules/bcryptjs
|
||||
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
RUN apk add --no-cache postgresql-client openssl
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Waiting for database..."
|
||||
until pg_isready -h db -p 5432 -U ecommerce; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Running migrations..."
|
||||
node node_modules/prisma/build/index.js migrate deploy
|
||||
|
||||
echo "Bootstrapping admin..."
|
||||
node scripts/bootstrap-admin.js
|
||||
|
||||
echo "Starting server..."
|
||||
exec node server.js
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
}
|
||||
module.exports = nextConfig
|
||||
Generated
+2013
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "ecommerce-platform",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.5",
|
||||
"@prisma/client": "^5.16.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"stripe": "^16.0.0",
|
||||
"nodemailer": "^6.9.14",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^5.16.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"typescript": "^5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"postcss": "^8",
|
||||
"autoprefixer": "^10.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('CUSTOMER', 'ADMIN', 'OWNER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProductStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OrderStatus" AS ENUM ('PENDING', 'PAID', 'CANCELLED', 'REFUNDED', 'FULFILLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ReviewStatus" AS ENUM ('PENDING', 'APPROVED', 'HIDDEN');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'CUSTOMER',
|
||||
"name" TEXT,
|
||||
"mustChangePassword" BOOLEAN NOT NULL DEFAULT false,
|
||||
"emailVerifiedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PasswordResetToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"usedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductType" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"schema" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProductType_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Product" (
|
||||
"id" TEXT NOT NULL,
|
||||
"typeId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"basePrice" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||
"status" "ProductStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"attributes" JSONB NOT NULL,
|
||||
"stock" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductVariant" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"sku" TEXT NOT NULL,
|
||||
"price" INTEGER NOT NULL,
|
||||
"stock" INTEGER NOT NULL,
|
||||
"attributes" JSONB NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProductVariant_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"parentId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductCategory" (
|
||||
"productId" TEXT NOT NULL,
|
||||
"categoryId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "ProductCategory_pkey" PRIMARY KEY ("productId","categoryId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MediaAsset" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productId" TEXT,
|
||||
"url" TEXT NOT NULL,
|
||||
"altText" TEXT,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MediaAsset_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Order" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"status" "OrderStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||
"subtotal" INTEGER NOT NULL,
|
||||
"taxTotal" INTEGER NOT NULL DEFAULT 0,
|
||||
"shippingTotal" INTEGER NOT NULL DEFAULT 0,
|
||||
"grandTotal" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrderItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"orderId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"unitPrice" INTEGER NOT NULL,
|
||||
"totalPrice" INTEGER NOT NULL,
|
||||
"metadata" JSONB,
|
||||
|
||||
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Payment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"orderId" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerPaymentId" TEXT,
|
||||
"status" TEXT NOT NULL,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL,
|
||||
"rawPayload" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Review" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"rating" INTEGER NOT NULL,
|
||||
"title" TEXT,
|
||||
"comment" TEXT,
|
||||
"status" "ReviewStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"verified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Review_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SiteSettings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"value" JSONB NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Page" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"seoTitle" TEXT,
|
||||
"seoDesc" TEXT,
|
||||
"published" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Page_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PageSection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"pageId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
"visible" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "PageSection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"entity" TEXT NOT NULL,
|
||||
"entityId" TEXT,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" ON "PasswordResetToken"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProductType_slug_key" ON "ProductType"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProductVariant_sku_key" ON "ProductVariant"("sku");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Payment_orderId_key" ON "Payment"("orderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Payment_providerPaymentId_key" ON "Payment"("providerPaymentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Review_userId_productId_key" ON "Review"("userId", "productId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SiteSettings_key_key" ON "SiteSettings"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Page_slug_key" ON "Page"("slug");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "ProductType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductCategory" ADD CONSTRAINT "ProductCategory_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductCategory" ADD CONSTRAINT "ProductCategory_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MediaAsset" ADD CONSTRAINT "MediaAsset_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Order" ADD CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Review" ADD CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Review" ADD CONSTRAINT "Review_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PageSection" ADD CONSTRAINT "PageSection_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "Page"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,257 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
CUSTOMER
|
||||
ADMIN
|
||||
OWNER
|
||||
}
|
||||
|
||||
enum ProductStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum OrderStatus {
|
||||
PENDING
|
||||
PAID
|
||||
CANCELLED
|
||||
REFUNDED
|
||||
FULFILLED
|
||||
}
|
||||
|
||||
enum ReviewStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
HIDDEN
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
role UserRole @default(CUSTOMER)
|
||||
name String?
|
||||
mustChangePassword Boolean @default(false)
|
||||
emailVerifiedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sessions Session[]
|
||||
orders Order[]
|
||||
reviews Review[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ProductType {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
schema Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
products Product[]
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
typeId String
|
||||
title String
|
||||
slug String @unique
|
||||
description String
|
||||
basePrice Int
|
||||
currency String @default("EUR")
|
||||
status ProductStatus @default(DRAFT)
|
||||
attributes Json
|
||||
stock Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
type ProductType @relation(fields: [typeId], references: [id])
|
||||
variants ProductVariant[]
|
||||
categories ProductCategory[]
|
||||
images MediaAsset[]
|
||||
orderItems OrderItem[]
|
||||
reviews Review[]
|
||||
}
|
||||
|
||||
model ProductVariant {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
sku String @unique
|
||||
price Int
|
||||
stock Int
|
||||
attributes Json
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
parentId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
products ProductCategory[]
|
||||
}
|
||||
|
||||
model ProductCategory {
|
||||
productId String
|
||||
categoryId String
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([productId, categoryId])
|
||||
}
|
||||
|
||||
model MediaAsset {
|
||||
id String @id @default(cuid())
|
||||
productId String?
|
||||
url String
|
||||
altText String?
|
||||
mimeType String
|
||||
size Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
model Order {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
status OrderStatus @default(PENDING)
|
||||
currency String @default("EUR")
|
||||
subtotal Int
|
||||
taxTotal Int @default(0)
|
||||
shippingTotal Int @default(0)
|
||||
grandTotal Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
items OrderItem[]
|
||||
payment Payment?
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
id String @id @default(cuid())
|
||||
orderId String
|
||||
productId String
|
||||
title String
|
||||
quantity Int
|
||||
unitPrice Int
|
||||
totalPrice Int
|
||||
metadata Json?
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(cuid())
|
||||
orderId String @unique
|
||||
provider String
|
||||
providerPaymentId String? @unique
|
||||
status String
|
||||
amount Int
|
||||
currency String
|
||||
rawPayload Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Review {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
productId String
|
||||
rating Int
|
||||
title String?
|
||||
comment String?
|
||||
status ReviewStatus @default(PENDING)
|
||||
verified Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, productId])
|
||||
}
|
||||
|
||||
model SiteSettings {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
value Json
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Page {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
title String
|
||||
seoTitle String?
|
||||
seoDesc String?
|
||||
published Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sections PageSection[]
|
||||
}
|
||||
|
||||
model PageSection {
|
||||
id String @id @default(cuid())
|
||||
pageId String
|
||||
type String
|
||||
sortOrder Int
|
||||
data Json
|
||||
visible Boolean @default(true)
|
||||
|
||||
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
action String
|
||||
entity String
|
||||
entityId String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
Vendored
+46
@@ -0,0 +1,46 @@
|
||||
const { PrismaClient } = require('@prisma/client')
|
||||
const bcrypt = require('bcryptjs')
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
const email = process.env.INITIAL_ADMIN_EMAIL
|
||||
const password = process.env.INITIAL_ADMIN_PASSWORD
|
||||
|
||||
if (!email || !password) {
|
||||
console.log('INITIAL_ADMIN_EMAIL or INITIAL_ADMIN_PASSWORD not set, skipping bootstrap')
|
||||
return
|
||||
}
|
||||
|
||||
const ownerCount = await prisma.user.count({
|
||||
where: { role: 'OWNER' },
|
||||
})
|
||||
|
||||
if (ownerCount > 0) {
|
||||
console.log('Owner already exists, skipping bootstrap')
|
||||
return
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
role: 'OWNER',
|
||||
name: 'Admin',
|
||||
mustChangePassword: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Created owner account: ${admin.email}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Bootstrap failed:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
const email = process.env.INITIAL_ADMIN_EMAIL
|
||||
const password = process.env.INITIAL_ADMIN_PASSWORD
|
||||
|
||||
if (!email || !password) {
|
||||
console.log('INITIAL_ADMIN_EMAIL or INITIAL_ADMIN_PASSWORD not set, skipping bootstrap')
|
||||
return
|
||||
}
|
||||
|
||||
const ownerCount = await prisma.user.count({
|
||||
where: { role: 'OWNER' },
|
||||
})
|
||||
|
||||
if (ownerCount > 0) {
|
||||
console.log('Owner already exists, skipping bootstrap')
|
||||
return
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
role: 'OWNER',
|
||||
name: 'Admin',
|
||||
mustChangePassword: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Created owner account: ${admin.email}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Bootstrap failed:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Navbar } from '@/components/storefront/Navbar'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
status: string
|
||||
grandTotal: number
|
||||
currency: string
|
||||
createdAt: string
|
||||
items: Array<{ title: string; quantity: number; unitPrice: number }>
|
||||
}
|
||||
|
||||
const statusVariant: Record<string, 'default' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
PENDING: 'warning',
|
||||
PAID: 'success',
|
||||
FULFILLED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
REFUNDED: 'info',
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<OrdersContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function OrdersContent() {
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const success = searchParams.get('success')
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('user')
|
||||
if (!stored) {
|
||||
router.push('/login?redirect=/account/orders')
|
||||
return
|
||||
}
|
||||
|
||||
fetch('/api/orders')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setOrders(data.orders || [])
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">My Orders</h1>
|
||||
<Link href="/account" className="text-sm text-blue-600 hover:underline">
|
||||
Back to Account
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<Alert variant="success" className="mb-6">
|
||||
Payment successful! Your order has been confirmed.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-24 bg-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
<p>No orders yet.</p>
|
||||
<Link href="/products" className="mt-3 inline-block text-blue-600 hover:underline">
|
||||
Start shopping
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{orders.map((order) => (
|
||||
<div key={order.id} className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Order #{order.id.slice(-8).toUpperCase()}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(order.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge variant={statusVariant[order.status] || 'default'}>
|
||||
{order.status}
|
||||
</Badge>
|
||||
<p className="text-sm font-bold mt-1">
|
||||
{(order.grandTotal / 100).toFixed(2)} {order.currency}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<ul className="space-y-1">
|
||||
{order.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-gray-600">
|
||||
{item.title} × {item.quantity} —{' '}
|
||||
{((item.unitPrice * item.quantity) / 100).toFixed(2)} EUR
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Navbar } from '@/components/storefront/Navbar'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name?: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export default function AccountPage() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('user')
|
||||
if (!stored) {
|
||||
router.push('/login?redirect=/account')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setUser(JSON.parse(stored))
|
||||
} catch {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<main className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">My Account</h1>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
|
||||
<h2 className="font-semibold mb-4">Profile</h2>
|
||||
<dl className="space-y-2">
|
||||
<div className="flex">
|
||||
<dt className="w-24 text-sm text-gray-500">Name:</dt>
|
||||
<dd className="text-sm font-medium">{user.name || '—'}</dd>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<dt className="w-24 text-sm text-gray-500">Email:</dt>
|
||||
<dd className="text-sm font-medium">{user.email}</dd>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<dt className="w-24 text-sm text-gray-500">Role:</dt>
|
||||
<dd className="text-sm font-medium capitalize">{user.role.toLowerCase()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Link
|
||||
href="/account/orders"
|
||||
className="bg-white rounded-lg border border-gray-200 p-6 hover:shadow-md transition-shadow text-center"
|
||||
>
|
||||
<div className="text-3xl mb-2">📦</div>
|
||||
<h3 className="font-semibold">My Orders</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">View your order history</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/products"
|
||||
className="bg-white rounded-lg border border-gray-200 p-6 hover:shadow-md transition-shadow text-center"
|
||||
>
|
||||
<div className="text-3xl mb-2">🛍️</div>
|
||||
<h3 className="font-semibold">Shop</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Browse our products</p>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Input, Select } from '@/components/ui/Input'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
interface AdminUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
role: string
|
||||
createdAt: string
|
||||
mustChangePassword: boolean
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [role, setRole] = useState('ADMIN')
|
||||
const [password, setPassword] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
async function loadUsers() {
|
||||
const res = await fetch('/api/admin/users')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUsers(data.users || [])
|
||||
} else {
|
||||
setError('You do not have permission to manage admin users (Owner role required)')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [])
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setCreating(true)
|
||||
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, name, role, password }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok) {
|
||||
setSuccess('Admin user created!')
|
||||
setShowForm(false)
|
||||
setEmail('')
|
||||
setName('')
|
||||
setPassword('')
|
||||
loadUsers()
|
||||
} else {
|
||||
setError(data.error || 'Failed to create user')
|
||||
}
|
||||
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
async function deleteUser(id: string) {
|
||||
if (!confirm('Delete this admin user?')) return
|
||||
const res = await fetch(`/api/admin/users?id=${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
loadUsers()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Admin Users</h1>
|
||||
<Button onClick={() => setShowForm(!showForm)}>
|
||||
{showForm ? 'Cancel' : '+ New Admin'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||
{success && <Alert variant="success" className="mb-4">{success}</Alert>}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
|
||||
<h2 className="font-semibold mb-4">New Admin User</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Role"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="OWNER">Owner</option>
|
||||
</Select>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
User will be required to change their password on first login.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" loading={creating}>Create</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">Loading...</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No admin users found.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Email</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Role</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Created</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium">{u.name || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{u.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={u.role === 'OWNER' ? 'info' : 'default'}>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{u.mustChangePassword ? (
|
||||
<span className="text-xs text-yellow-600">Must change password</span>
|
||||
) : (
|
||||
<span className="text-xs text-green-600">Active</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs">
|
||||
{new Date(u.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => deleteUser(u.id)}
|
||||
className="text-red-600 hover:underline text-xs"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Input, Select } from '@/components/ui/Input'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
parentId: string | null
|
||||
_count: { products: number }
|
||||
}
|
||||
|
||||
export default function AdminCategoriesPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editId, setEditId] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [parentId, setParentId] = useState('')
|
||||
|
||||
async function loadCategories() {
|
||||
const res = await fetch('/api/admin/categories')
|
||||
const data = await res.json()
|
||||
setCategories(data.categories || [])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories()
|
||||
}, [])
|
||||
|
||||
function generateSlug(n: string) {
|
||||
return n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
const url = '/api/admin/categories'
|
||||
const method = editId ? 'PUT' : 'POST'
|
||||
const body = editId
|
||||
? { id: editId, name, slug, parentId: parentId || null }
|
||||
: { name, slug, parentId: parentId || null }
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setSuccess(editId ? 'Updated!' : 'Created!')
|
||||
setShowForm(false)
|
||||
setEditId(null)
|
||||
setName('')
|
||||
setSlug('')
|
||||
setParentId('')
|
||||
loadCategories()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Failed')
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(c: Category) {
|
||||
setEditId(c.id)
|
||||
setName(c.name)
|
||||
setSlug(c.slug)
|
||||
setParentId(c.parentId || '')
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Delete this category?')) return
|
||||
const res = await fetch(`/api/admin/categories?id=${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
loadCategories()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Categories</h1>
|
||||
<Button onClick={() => { setShowForm(!showForm); setEditId(null); setName(''); setSlug(''); setParentId('') }}>
|
||||
{showForm ? 'Cancel' : '+ New Category'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||
{success && <Alert variant="success" className="mb-4">{success}</Alert>}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
|
||||
<h2 className="font-semibold mb-4">{editId ? 'Edit Category' : 'New Category'}</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (!editId) setSlug(generateSlug(e.target.value))
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label="Parent Category (optional)"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
>
|
||||
<option value="">None (top-level)</option>
|
||||
{categories
|
||||
.filter((c) => c.id !== editId)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit">{editId ? 'Update' : 'Create'}</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">Loading...</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No categories yet.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Slug</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Parent</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Products</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{categories.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium">{c.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{c.slug}</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{c.parentId ? categories.find((p) => p.id === c.parentId)?.name || '—' : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{c._count.products}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => startEdit(c)} className="text-blue-600 hover:underline text-xs">
|
||||
Edit
|
||||
</button>
|
||||
{c._count.products === 0 && (
|
||||
<button onClick={() => handleDelete(c.id)} className="text-red-600 hover:underline text-xs">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (newPassword !== confirm) {
|
||||
setError('New passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Failed to change password')
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
// Update localStorage to remove mustChangePassword flag
|
||||
const userStr = localStorage.getItem('user')
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr)
|
||||
user.mustChangePassword = false
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
}
|
||||
|
||||
setTimeout(() => router.push('/admin'), 1500)
|
||||
} catch {
|
||||
setError('Something went wrong')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-md">
|
||||
<h1 className="text-2xl font-bold mb-2">Change Password</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
You must change your password before continuing.
|
||||
</p>
|
||||
|
||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||
{success && (
|
||||
<Alert variant="success" className="mb-4">
|
||||
Password changed successfully! Redirecting...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Min 12 chars, uppercase, lowercase, number, symbol.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" loading={loading} className="w-full">
|
||||
Change Password
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
async function getCustomers() {
|
||||
return prisma.user.findMany({
|
||||
where: { role: 'CUSTOMER' },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
emailVerifiedAt: true,
|
||||
_count: { select: { orders: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
export default async function AdminCustomersPage() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) redirect('/login')
|
||||
|
||||
const customers = await getCustomers()
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Customers</h1>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{customers.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No customers yet.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Customer</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Email</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Orders</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Verified</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{customers.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium">{c.name || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{c.email}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{c._count.orders}</td>
|
||||
<td className="px-4 py-3">
|
||||
{c.emailVerifiedAt ? (
|
||||
<span className="text-green-600 text-xs">Verified</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">Not verified</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs">
|
||||
{new Date(c.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { headers } from 'next/headers'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { AdminNav } from '@/components/admin/AdminNav'
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const user = await getCurrentUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
if (user.role === 'CUSTOMER') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Access Denied</h1>
|
||||
<p className="text-gray-600">You do not have permission to access this area.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (user.mustChangePassword) {
|
||||
const headersList = await headers()
|
||||
const pathname = headersList.get('x-pathname') ?? ''
|
||||
if (!pathname.startsWith('/admin/change-password')) {
|
||||
redirect('/admin/change-password')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<AdminNav />
|
||||
<main className="flex-1 bg-gray-50 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Select } from '@/components/ui/Input'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
status: string
|
||||
grandTotal: number
|
||||
currency: string
|
||||
subtotal: number
|
||||
taxTotal: number
|
||||
shippingTotal: number
|
||||
createdAt: string
|
||||
user: { email: string; name: string | null }
|
||||
items: Array<{
|
||||
id: string
|
||||
title: string
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
totalPrice: number
|
||||
product: { title: string; slug: string }
|
||||
}>
|
||||
payment: {
|
||||
provider: string
|
||||
status: string
|
||||
amount: number
|
||||
providerPaymentId: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
const statusVariant: Record<string, 'default' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
PENDING: 'warning',
|
||||
PAID: 'success',
|
||||
FULFILLED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
REFUNDED: 'info',
|
||||
}
|
||||
|
||||
export default function AdminOrderDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const [order, setOrder] = useState<Order | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [newStatus, setNewStatus] = useState('')
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/admin/orders/${params.id}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setOrder(data.order)
|
||||
setNewStatus(data.order?.status || '')
|
||||
setLoading(false)
|
||||
})
|
||||
}, [params.id])
|
||||
|
||||
async function updateStatus() {
|
||||
setUpdating(true)
|
||||
setError('')
|
||||
setMessage('')
|
||||
const res = await fetch(`/api/admin/orders/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setOrder((o) => o ? { ...o, status: data.order.status } : o)
|
||||
setMessage('Status updated!')
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Failed')
|
||||
}
|
||||
setUpdating(false)
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-gray-500">Loading...</div>
|
||||
if (!order) return <div className="p-8 text-gray-500">Order not found</div>
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">
|
||||
Order #{order.id.slice(-8).toUpperCase()}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => router.push('/admin/orders')}
|
||||
className="text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Back to Orders
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||
{message && <Alert variant="success" className="mb-4">{message}</Alert>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="font-semibold mb-3">Customer</h2>
|
||||
<p className="text-sm font-medium">{order.user.name || '—'}</p>
|
||||
<p className="text-sm text-gray-600">{order.user.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="font-semibold mb-3">Status</h2>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Badge variant={statusVariant[order.status] || 'default'}>
|
||||
{order.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(order.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={newStatus}
|
||||
onChange={(e) => setNewStatus(e.target.value)}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="PENDING">PENDING</option>
|
||||
<option value="PAID">PAID</option>
|
||||
<option value="FULFILLED">FULFILLED</option>
|
||||
<option value="CANCELLED">CANCELLED</option>
|
||||
<option value="REFUNDED">REFUNDED</option>
|
||||
</Select>
|
||||
<Button size="sm" loading={updating} onClick={updateStatus}>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 mb-6">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="font-semibold">Items</h2>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-gray-600">Product</th>
|
||||
<th className="text-right px-4 py-2 text-gray-600">Qty</th>
|
||||
<th className="text-right px-4 py-2 text-gray-600">Unit Price</th>
|
||||
<th className="text-right px-4 py-2 text-gray-600">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{order.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-4 py-3">{item.title}</td>
|
||||
<td className="px-4 py-3 text-right">{item.quantity}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{(item.unitPrice / 100).toFixed(2)} {order.currency}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium">
|
||||
{(item.totalPrice / 100).toFixed(2)} {order.currency}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50">
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-2 text-right font-medium">Subtotal</td>
|
||||
<td className="px-4 py-2 text-right">{(order.subtotal / 100).toFixed(2)} {order.currency}</td>
|
||||
</tr>
|
||||
{order.taxTotal > 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-2 text-right font-medium">Tax</td>
|
||||
<td className="px-4 py-2 text-right">{(order.taxTotal / 100).toFixed(2)} {order.currency}</td>
|
||||
</tr>
|
||||
)}
|
||||
{order.shippingTotal > 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-2 text-right font-medium">Shipping</td>
|
||||
<td className="px-4 py-2 text-right">{(order.shippingTotal / 100).toFixed(2)} {order.currency}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="border-t">
|
||||
<td colSpan={3} className="px-4 py-2 text-right font-bold">Total</td>
|
||||
<td className="px-4 py-2 text-right font-bold">
|
||||
{(order.grandTotal / 100).toFixed(2)} {order.currency}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{order.payment && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="font-semibold mb-3">Payment</h2>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex">
|
||||
<dt className="w-36 text-gray-500">Provider:</dt>
|
||||
<dd className="font-medium capitalize">{order.payment.provider}</dd>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<dt className="w-36 text-gray-500">Status:</dt>
|
||||
<dd className="font-medium">{order.payment.status}</dd>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<dt className="w-36 text-gray-500">Amount:</dt>
|
||||
<dd className="font-medium">
|
||||
{(order.payment.amount / 100).toFixed(2)} {order.currency}
|
||||
</dd>
|
||||
</div>
|
||||
{order.payment.providerPaymentId && (
|
||||
<div className="flex">
|
||||
<dt className="w-36 text-gray-500">Payment ID:</dt>
|
||||
<dd className="font-mono text-xs">{order.payment.providerPaymentId}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
status: string
|
||||
grandTotal: number
|
||||
currency: string
|
||||
createdAt: string
|
||||
user: { email: string; name: string | null }
|
||||
items: Array<{ title: string; quantity: number }>
|
||||
}
|
||||
|
||||
const statusVariant: Record<string, 'default' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
PENDING: 'warning',
|
||||
PAID: 'success',
|
||||
FULFILLED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
REFUNDED: 'info',
|
||||
}
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
const s = searchParams.get('status') || ''
|
||||
setStatusFilter(s)
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams()
|
||||
if (statusFilter) params.set('status', statusFilter)
|
||||
|
||||
fetch(`/api/admin/orders?${params}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setOrders(data.orders || [])
|
||||
setLoading(false)
|
||||
})
|
||||
}, [statusFilter])
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Orders</h1>
|
||||
|
||||
<div className="flex gap-3 mb-6">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="PAID">Paid</option>
|
||||
<option value="FULFILLED">Fulfilled</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
<option value="REFUNDED">Refunded</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">Loading...</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No orders found.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Order</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Customer</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Items</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Total</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Date</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
#{order.id.slice(-8).toUpperCase()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium">{order.user.name || '—'}</p>
|
||||
<p className="text-xs text-gray-500">{order.user.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{order.items.map((i) => `${i.title} ×${i.quantity}`).join(', ')}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
{(order.grandTotal / 100).toFixed(2)} {order.currency}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={statusVariant[order.status] || 'default'}>
|
||||
{order.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs">
|
||||
{new Date(order.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/admin/orders/${order.id}`}
|
||||
className="text-blue-600 hover:underline text-xs"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
|
||||
async function getDashboardData() {
|
||||
const [totalOrders, pendingOrders, totalRevenue, totalProducts, totalCustomers, recentOrders, pendingReviews] =
|
||||
await Promise.all([
|
||||
prisma.order.count(),
|
||||
prisma.order.count({ where: { status: 'PENDING' } }),
|
||||
prisma.order.aggregate({
|
||||
where: { status: { in: ['PAID', 'FULFILLED'] } },
|
||||
_sum: { grandTotal: true },
|
||||
}),
|
||||
prisma.product.count({ where: { status: { not: 'ARCHIVED' } } }),
|
||||
prisma.user.count({ where: { role: 'CUSTOMER' } }),
|
||||
prisma.order.findMany({
|
||||
take: 5,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { user: { select: { email: true, name: true } } },
|
||||
}),
|
||||
prisma.review.count({ where: { status: 'PENDING' } }),
|
||||
])
|
||||
|
||||
return {
|
||||
stats: {
|
||||
totalOrders,
|
||||
pendingOrders,
|
||||
totalRevenue: totalRevenue._sum.grandTotal ?? 0,
|
||||
totalProducts,
|
||||
totalCustomers,
|
||||
pendingReviews,
|
||||
},
|
||||
recentOrders,
|
||||
}
|
||||
}
|
||||
|
||||
const statusVariant: Record<string, 'default' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
PENDING: 'warning',
|
||||
PAID: 'success',
|
||||
FULFILLED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
REFUNDED: 'info',
|
||||
}
|
||||
|
||||
export default async function AdminDashboardPage() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) redirect('/login')
|
||||
if (user.mustChangePassword) redirect('/admin/change-password')
|
||||
|
||||
const { stats, recentOrders } = await getDashboardData()
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-8">Dashboard</h1>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<p className="text-sm text-gray-500">Total Orders</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.totalOrders}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<p className="text-sm text-gray-500">Pending Orders</p>
|
||||
<p className="text-2xl font-bold mt-1 text-yellow-600">{stats.pendingOrders}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<p className="text-sm text-gray-500">Revenue</p>
|
||||
<p className="text-2xl font-bold mt-1 text-green-600">
|
||||
{(stats.totalRevenue / 100).toFixed(0)} EUR
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<p className="text-sm text-gray-500">Products</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.totalProducts}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<p className="text-sm text-gray-500">Customers</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.totalCustomers}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<p className="text-sm text-gray-500">Pending Reviews</p>
|
||||
<p className="text-2xl font-bold mt-1 text-blue-600">{stats.pendingReviews}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<Link
|
||||
href="/admin/products/new"
|
||||
className="bg-blue-600 text-white rounded-lg p-4 text-center hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<p className="font-semibold">+ New Product</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/orders?status=PENDING"
|
||||
className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-center hover:bg-yellow-100 transition-colors"
|
||||
>
|
||||
<p className="font-semibold text-yellow-800">View Pending Orders</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/reviews?status=PENDING"
|
||||
className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-center hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<p className="font-semibold text-blue-800">Review Pending Reviews</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<p className="font-semibold text-gray-800">Settings</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Recent orders */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="font-semibold">Recent Orders</h2>
|
||||
<Link href="/admin/orders" className="text-sm text-blue-600 hover:underline">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
{recentOrders.length === 0 ? (
|
||||
<div className="px-6 py-8 text-center text-gray-500 text-sm">No orders yet</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{recentOrders.map((order) => (
|
||||
<Link
|
||||
key={order.id}
|
||||
href={`/admin/orders/${order.id}`}
|
||||
className="flex items-center justify-between px-6 py-3 hover:bg-gray-50"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">#{order.id.slice(-8).toUpperCase()}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{order.user.name || order.user.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={statusVariant[order.status] || 'default'}>
|
||||
{order.status}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">
|
||||
{(order.grandTotal / 100).toFixed(2)} EUR
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(order.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
interface ProductType {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
schema: object
|
||||
_count: { products: number }
|
||||
}
|
||||
|
||||
export default function AdminProductTypesPage() {
|
||||
const [types, setTypes] = useState<ProductType[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editId, setEditId] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [schemaStr, setSchemaStr] = useState('{}')
|
||||
|
||||
async function loadTypes() {
|
||||
const res = await fetch('/api/admin/product-types')
|
||||
const data = await res.json()
|
||||
setTypes(data.types || [])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTypes()
|
||||
}, [])
|
||||
|
||||
function generateSlug(n: string) {
|
||||
return n
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
let schema: object
|
||||
try {
|
||||
schema = JSON.parse(schemaStr)
|
||||
} catch {
|
||||
setError('Schema must be valid JSON')
|
||||
return
|
||||
}
|
||||
|
||||
const url = '/api/admin/product-types'
|
||||
const method = editId ? 'PUT' : 'POST'
|
||||
const body = editId
|
||||
? { id: editId, name, slug, schema }
|
||||
: { name, slug, schema }
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setSuccess(editId ? 'Updated!' : 'Created!')
|
||||
setShowForm(false)
|
||||
setEditId(null)
|
||||
setName('')
|
||||
setSlug('')
|
||||
setSchemaStr('{}')
|
||||
loadTypes()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Failed')
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(t: ProductType) {
|
||||
setEditId(t.id)
|
||||
setName(t.name)
|
||||
setSlug(t.slug)
|
||||
setSchemaStr(JSON.stringify(t.schema, null, 2))
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Delete this product type?')) return
|
||||
const res = await fetch(`/api/admin/product-types?id=${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
loadTypes()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Product Types</h1>
|
||||
<Button onClick={() => { setShowForm(!showForm); setEditId(null); setName(''); setSlug(''); setSchemaStr('{}') }}>
|
||||
{showForm ? 'Cancel' : '+ New Type'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||
{success && <Alert variant="success" className="mb-4">{success}</Alert>}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
|
||||
<h2 className="font-semibold mb-4">{editId ? 'Edit Type' : 'New Product Type'}</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (!editId) setSlug(generateSlug(e.target.value))
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Schema (JSON — define attribute fields)
|
||||
</label>
|
||||
<textarea
|
||||
value={schemaStr}
|
||||
onChange={(e) => setSchemaStr(e.target.value)}
|
||||
rows={8}
|
||||
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={'{\n "fields": [\n {"name": "color", "type": "string"}\n ]\n}'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit">{editId ? 'Update' : 'Create'}</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">Loading...</div>
|
||||
) : types.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No product types yet.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Slug</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Products</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{types.map((t) => (
|
||||
<tr key={t.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium">{t.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{t.slug}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{t._count.products}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => startEdit(t)} className="text-blue-600 hover:underline text-xs">
|
||||
Edit
|
||||
</button>
|
||||
{t._count.products === 0 && (
|
||||
<button onClick={() => handleDelete(t.id)} className="text-red-600 hover:underline text-xs">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Input, Textarea, Select } from '@/components/ui/Input'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
interface ProductType {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ProductForm {
|
||||
typeId: string
|
||||
title: string
|
||||
slug: string
|
||||
description: string
|
||||
basePrice: string
|
||||
currency: string
|
||||
status: string
|
||||
attributes: string
|
||||
stock: string
|
||||
categoryIds: string[]
|
||||
}
|
||||
|
||||
export default function AdminProductEditPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const isNew = params.id === 'new'
|
||||
|
||||
const [form, setForm] = useState<ProductForm>({
|
||||
typeId: '',
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
basePrice: '0',
|
||||
currency: 'EUR',
|
||||
status: 'DRAFT',
|
||||
attributes: '{}',
|
||||
stock: '',
|
||||
categoryIds: [],
|
||||
})
|
||||
|
||||
const [productTypes, setProductTypes] = useState<ProductType[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
// Load types and categories
|
||||
Promise.all([
|
||||
fetch('/api/admin/product-types').then((r) => r.json()),
|
||||
fetch('/api/admin/categories').then((r) => r.json()),
|
||||
]).then(([typesData, catsData]) => {
|
||||
setProductTypes(typesData.types || [])
|
||||
setCategories(catsData.categories || [])
|
||||
})
|
||||
|
||||
if (!isNew) {
|
||||
fetch(`/api/admin/products/${params.id}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.product) {
|
||||
const p = data.product
|
||||
setForm({
|
||||
typeId: p.typeId,
|
||||
title: p.title,
|
||||
slug: p.slug,
|
||||
description: p.description,
|
||||
basePrice: String(p.basePrice),
|
||||
currency: p.currency,
|
||||
status: p.status,
|
||||
attributes: JSON.stringify(p.attributes, null, 2),
|
||||
stock: p.stock != null ? String(p.stock) : '',
|
||||
categoryIds: p.categories.map((c: { categoryId: string }) => c.categoryId),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [isNew, params.id])
|
||||
|
||||
function generateSlug(title: string) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const title = e.target.value
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
title,
|
||||
slug: isNew ? generateSlug(title) : f.slug,
|
||||
}))
|
||||
}
|
||||
|
||||
function toggleCategory(id: string) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
categoryIds: f.categoryIds.includes(id)
|
||||
? f.categoryIds.filter((c) => c !== id)
|
||||
: [...f.categoryIds, id],
|
||||
}))
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
setLoading(true)
|
||||
|
||||
let attributes: object
|
||||
try {
|
||||
attributes = JSON.parse(form.attributes)
|
||||
} catch {
|
||||
setError('Attributes must be valid JSON')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
typeId: form.typeId,
|
||||
title: form.title,
|
||||
slug: form.slug,
|
||||
description: form.description,
|
||||
basePrice: parseInt(form.basePrice),
|
||||
currency: form.currency,
|
||||
status: form.status,
|
||||
attributes,
|
||||
stock: form.stock ? parseInt(form.stock) : null,
|
||||
categoryIds: form.categoryIds,
|
||||
}
|
||||
|
||||
const url = isNew ? '/api/admin/products' : `/api/admin/products/${params.id}`
|
||||
const method = isNew ? 'POST' : 'PUT'
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Save failed')
|
||||
} else {
|
||||
setSuccess('Saved successfully!')
|
||||
if (isNew) {
|
||||
router.push(`/admin/products/${data.product.id}`)
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">{isNew ? 'New Product' : 'Edit Product'}</h1>
|
||||
<button
|
||||
onClick={() => router.push('/admin/products')}
|
||||
className="text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Back to Products
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||
{success && <Alert variant="success" className="mb-4">{success}</Alert>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||
<h2 className="font-semibold">Basic Information</h2>
|
||||
|
||||
<Select
|
||||
label="Product Type"
|
||||
value={form.typeId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, typeId: e.target.value }))}
|
||||
required
|
||||
>
|
||||
<option value="">Select a type...</option>
|
||||
{productTypes.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
label="Title"
|
||||
value={form.title}
|
||||
onChange={handleTitleChange}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Slug"
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||
<h2 className="font-semibold">Pricing & Inventory</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Base Price (cents)"
|
||||
type="number"
|
||||
value={form.basePrice}
|
||||
onChange={(e) => setForm((f) => ({ ...f, basePrice: e.target.value }))}
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
value={form.currency}
|
||||
onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))}
|
||||
>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="GBP">GBP</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Stock (leave empty for unlimited)"
|
||||
type="number"
|
||||
value={form.stock}
|
||||
onChange={(e) => setForm((f) => ({ ...f, stock: e.target.value }))}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||
<h2 className="font-semibold">Status & Organization</h2>
|
||||
|
||||
<Select
|
||||
label="Status"
|
||||
value={form.status}
|
||||
onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}
|
||||
>
|
||||
<option value="DRAFT">Draft</option>
|
||||
<option value="PUBLISHED">Published</option>
|
||||
<option value="ARCHIVED">Archived</option>
|
||||
</Select>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Categories</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((cat) => (
|
||||
<label key={cat.id} className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.categoryIds.includes(cat.id)}
|
||||
onChange={() => toggleCategory(cat.id)}
|
||||
/>
|
||||
<span className="text-sm">{cat.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||
<h2 className="font-semibold">Custom Attributes (JSON)</h2>
|
||||
<Textarea
|
||||
label="Attributes"
|
||||
value={form.attributes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, attributes: e.target.value }))}
|
||||
rows={6}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" loading={loading}>
|
||||
{isNew ? 'Create Product' : 'Save Changes'}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => router.push('/admin/products')}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
status: string
|
||||
basePrice: number
|
||||
currency: string
|
||||
createdAt: string
|
||||
type: { name: string }
|
||||
_count: { variants: number; orderItems: number }
|
||||
}
|
||||
|
||||
const statusVariant: Record<string, 'default' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
DRAFT: 'warning',
|
||||
PUBLISHED: 'success',
|
||||
ARCHIVED: 'danger',
|
||||
}
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
async function loadProducts() {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set('search', search)
|
||||
if (statusFilter) params.set('status', statusFilter)
|
||||
|
||||
const res = await fetch(`/api/admin/products?${params}`)
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
setProducts(data.products)
|
||||
} else {
|
||||
setError(data.error)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadProducts()
|
||||
}, [statusFilter])
|
||||
|
||||
async function archiveProduct(id: string) {
|
||||
if (!confirm('Archive this product?')) return
|
||||
const res = await fetch(`/api/admin/products/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
loadProducts()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Products</h1>
|
||||
<Link href="/admin/products/new">
|
||||
<Button>+ New Product</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||
|
||||
<div className="flex gap-3 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && loadProducts()}
|
||||
className="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="DRAFT">Draft</option>
|
||||
<option value="PUBLISHED">Published</option>
|
||||
<option value="ARCHIVED">Archived</option>
|
||||
</select>
|
||||
<Button variant="secondary" onClick={loadProducts}>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">Loading...</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No products found.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Product</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Type</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Price</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Orders</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Created</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{products.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium">{product.title}</p>
|
||||
<p className="text-xs text-gray-500">{product.slug}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{product.type.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
{(product.basePrice / 100).toFixed(2)} {product.currency}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={statusVariant[product.status] || 'default'}>
|
||||
{product.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{product._count.orderItems}</td>
|
||||
<td className="px-4 py-3 text-gray-500">
|
||||
{new Date(product.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={`/admin/products/${product.id}`}
|
||||
className="text-blue-600 hover:underline text-xs"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => archiveProduct(product.id)}
|
||||
className="text-red-600 hover:underline text-xs"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
interface Review {
|
||||
id: string
|
||||
rating: number
|
||||
title: string | null
|
||||
comment: string | null
|
||||
status: string
|
||||
verified: boolean
|
||||
createdAt: string
|
||||
user: { email: string; name: string | null }
|
||||
product: { title: string; slug: string }
|
||||
}
|
||||
|
||||
const statusVariant: Record<string, 'default' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
PENDING: 'warning',
|
||||
APPROVED: 'success',
|
||||
HIDDEN: 'danger',
|
||||
}
|
||||
|
||||
export default function AdminReviewsPage() {
|
||||
const [reviews, setReviews] = useState<Review[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
async function loadReviews() {
|
||||
const params = new URLSearchParams()
|
||||
if (statusFilter) params.set('status', statusFilter)
|
||||
const res = await fetch(`/api/admin/reviews?${params}`)
|
||||
const data = await res.json()
|
||||
setReviews(data.reviews || [])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadReviews()
|
||||
}, [statusFilter])
|
||||
|
||||
async function updateStatus(id: string, status: string) {
|
||||
const res = await fetch('/api/admin/reviews', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, status }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setMessage(`Review ${status.toLowerCase()}!`)
|
||||
loadReviews()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Reviews</h1>
|
||||
|
||||
{message && <Alert variant="success" className="mb-4">{message}</Alert>}
|
||||
|
||||
<div className="flex gap-3 mb-6">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="APPROVED">Approved</option>
|
||||
<option value="HIDDEN">Hidden</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">Loading...</div>
|
||||
) : reviews.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No reviews found.</div>
|
||||
) : (
|
||||
reviews.map((review) => (
|
||||
<div key={review.id} className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<span key={star} className={star <= review.rating ? 'text-yellow-400' : 'text-gray-300'}>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Badge variant={statusVariant[review.status] || 'default'}>
|
||||
{review.status}
|
||||
</Badge>
|
||||
{review.verified && (
|
||||
<span className="text-xs text-green-600">Verified purchase</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium">{review.product.title}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
by {review.user.name || review.user.email} •{' '}
|
||||
{new Date(review.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
{review.title && (
|
||||
<p className="mt-2 font-medium">{review.title}</p>
|
||||
)}
|
||||
{review.comment && (
|
||||
<p className="mt-1 text-sm text-gray-600">{review.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
{review.status !== 'APPROVED' && (
|
||||
<Button size="sm" variant="secondary" onClick={() => updateStatus(review.id, 'APPROVED')}>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
{review.status !== 'HIDDEN' && (
|
||||
<Button size="sm" variant="danger" onClick={() => updateStatus(review.id, 'HIDDEN')}>
|
||||
Hide
|
||||
</Button>
|
||||
)}
|
||||
{review.status !== 'PENDING' && (
|
||||
<Button size="sm" variant="ghost" onClick={() => updateStatus(review.id, 'PENDING')}>
|
||||
Pending
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
const DEFAULT_SETTINGS = [
|
||||
{ key: 'site_name', label: 'Site Name', type: 'text', defaultValue: 'ShopX' },
|
||||
{ key: 'site_description', label: 'Site Description', type: 'text', defaultValue: 'Your online store' },
|
||||
{ key: 'support_email', label: 'Support Email', type: 'email', defaultValue: 'support@example.com' },
|
||||
{ key: 'currency', label: 'Default Currency', type: 'text', defaultValue: 'EUR' },
|
||||
{ key: 'tax_rate', label: 'Tax Rate (%)', type: 'number', defaultValue: '0' },
|
||||
]
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/settings')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const settings = data.settings || {}
|
||||
const initial: Record<string, string> = {}
|
||||
DEFAULT_SETTINGS.forEach((s) => {
|
||||
initial[s.key] = String((settings[s.key] as string | undefined) ?? s.defaultValue)
|
||||
})
|
||||
setValues(initial)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
async function saveSetting(key: string, value: string) {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value }),
|
||||
})
|
||||
setSaving(false)
|
||||
if (res.ok) {
|
||||
setMessage('Settings saved!')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Failed to save')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
|
||||
for (const s of DEFAULT_SETTINGS) {
|
||||
await fetch('/api/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: s.key, value: values[s.key] }),
|
||||
})
|
||||
}
|
||||
|
||||
setSaving(false)
|
||||
setMessage('All settings saved!')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-gray-500">Loading...</div>
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl">
|
||||
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
||||
|
||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||
{message && <Alert variant="success" className="mb-4">{message}</Alert>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||
{DEFAULT_SETTINGS.map((setting) => (
|
||||
<Input
|
||||
key={setting.key}
|
||||
label={setting.label}
|
||||
type={setting.type}
|
||||
value={values[setting.key] || ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [setting.key]: e.target.value }))}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="pt-2">
|
||||
<Button type="submit" loading={saving}>
|
||||
Save All Settings
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { categorySchema } from '@/lib/validate'
|
||||
|
||||
async function requireAdmin() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
||||
return user
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const categories = await prisma.category.findMany({
|
||||
include: { _count: { select: { products: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
return NextResponse.json({ categories })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = categorySchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const category = await prisma.category.create({ data: parsed.data })
|
||||
|
||||
return NextResponse.json({ category }, { status: 201 })
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { id, ...data } = body as { id: string; name?: string; slug?: string; parentId?: string }
|
||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
|
||||
|
||||
const category = await prisma.category.update({ where: { id }, data })
|
||||
|
||||
return NextResponse.json({ category })
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get('id')
|
||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
|
||||
|
||||
await prisma.category.delete({ where: { id } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
|
||||
export async function GET() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const [
|
||||
totalOrders,
|
||||
pendingOrders,
|
||||
totalRevenue,
|
||||
totalProducts,
|
||||
totalCustomers,
|
||||
recentOrders,
|
||||
pendingReviews,
|
||||
] = await Promise.all([
|
||||
prisma.order.count(),
|
||||
prisma.order.count({ where: { status: 'PENDING' } }),
|
||||
prisma.order.aggregate({
|
||||
where: { status: { in: ['PAID', 'FULFILLED'] } },
|
||||
_sum: { grandTotal: true },
|
||||
}),
|
||||
prisma.product.count(),
|
||||
prisma.user.count({ where: { role: 'CUSTOMER' } }),
|
||||
prisma.order.findMany({
|
||||
take: 5,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { user: { select: { email: true, name: true } } },
|
||||
}),
|
||||
prisma.review.count({ where: { status: 'PENDING' } }),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
stats: {
|
||||
totalOrders,
|
||||
pendingOrders,
|
||||
totalRevenue: totalRevenue._sum.grandTotal ?? 0,
|
||||
totalProducts,
|
||||
totalCustomers,
|
||||
pendingReviews,
|
||||
},
|
||||
recentOrders,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
|
||||
async function requireAdmin() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
||||
return user
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
user: { select: { email: true, name: true } },
|
||||
items: { include: { product: { select: { title: true, slug: true } } } },
|
||||
payment: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!order) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
return NextResponse.json({ order })
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { status } = body as { status: string }
|
||||
|
||||
const validStatuses = ['PENDING', 'PAID', 'CANCELLED', 'REFUNDED', 'FULFILLED']
|
||||
if (!validStatuses.includes(status)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
|
||||
const order = await prisma.order.update({
|
||||
where: { id: params.id },
|
||||
data: { status: status as 'PENDING' | 'PAID' | 'CANCELLED' | 'REFUNDED' | 'FULFILLED' },
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'UPDATE_STATUS',
|
||||
entity: 'Order',
|
||||
entityId: order.id,
|
||||
metadata: { status },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ order })
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
|
||||
async function requireAdmin() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
||||
return user
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const status = searchParams.get('status')
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
const where: Record<string, unknown> = {}
|
||||
if (status) where.status = status
|
||||
|
||||
const [orders, total] = await Promise.all([
|
||||
prisma.order.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
user: { select: { email: true, name: true } },
|
||||
items: true,
|
||||
payment: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.order.count({ where }),
|
||||
])
|
||||
|
||||
return NextResponse.json({ orders, pagination: { page, limit, total, pages: Math.ceil(total / limit) } })
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { productTypeSchema } from '@/lib/validate'
|
||||
|
||||
async function requireAdmin() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
||||
return user
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const types = await prisma.productType.findMany({
|
||||
include: { _count: { select: { products: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
return NextResponse.json({ types })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = productTypeSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const type = await prisma.productType.create({ data: parsed.data })
|
||||
|
||||
return NextResponse.json({ type }, { status: 201 })
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { id, ...data } = body as { id: string; name?: string; slug?: string; schema?: object }
|
||||
|
||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
|
||||
|
||||
const type = await prisma.productType.update({ where: { id }, data })
|
||||
|
||||
return NextResponse.json({ type })
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get('id')
|
||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
|
||||
|
||||
await prisma.productType.delete({ where: { id } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { productSchema } from '@/lib/validate'
|
||||
|
||||
async function requireAdmin() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
||||
return user
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
type: true,
|
||||
categories: { include: { category: true } },
|
||||
images: true,
|
||||
variants: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!product) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
return NextResponse.json({ product })
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = productSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { categoryIds, ...data } = parsed.data
|
||||
|
||||
const product = await prisma.$transaction(async (tx) => {
|
||||
await tx.productCategory.deleteMany({ where: { productId: params.id } })
|
||||
|
||||
return tx.product.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
...data,
|
||||
categories: categoryIds?.length
|
||||
? { create: categoryIds.map((id) => ({ categoryId: id })) }
|
||||
: undefined,
|
||||
},
|
||||
include: { type: true, categories: { include: { category: true } } },
|
||||
})
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'UPDATE',
|
||||
entity: 'Product',
|
||||
entityId: product.id,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ product })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
await prisma.product.update({
|
||||
where: { id: params.id },
|
||||
data: { status: 'ARCHIVED' },
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'ARCHIVE',
|
||||
entity: 'Product',
|
||||
entityId: params.id,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { productSchema } from '@/lib/validate'
|
||||
|
||||
async function requireAdmin() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) {
|
||||
return null
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const search = searchParams.get('search')
|
||||
const status = searchParams.get('status')
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
if (status) where.status = status
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ slug: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const [products, total] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
type: true,
|
||||
categories: { include: { category: true } },
|
||||
images: { take: 1 },
|
||||
_count: { select: { variants: true, orderItems: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.product.count({ where }),
|
||||
])
|
||||
|
||||
return NextResponse.json({ products, pagination: { page, limit, total, pages: Math.ceil(total / limit) } })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = productSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message || 'Invalid input', details: parsed.error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { categoryIds, ...data } = parsed.data
|
||||
|
||||
const product = await prisma.product.create({
|
||||
data: {
|
||||
...data,
|
||||
categories: categoryIds?.length
|
||||
? {
|
||||
create: categoryIds.map((id) => ({ categoryId: id })),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: { type: true, categories: { include: { category: true } } },
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'CREATE',
|
||||
entity: 'Product',
|
||||
entityId: product.id,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ product }, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
|
||||
async function requireAdmin() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
||||
return user
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const status = searchParams.get('status')
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
const where: Record<string, unknown> = {}
|
||||
if (status) where.status = status
|
||||
|
||||
const [reviews, total] = await Promise.all([
|
||||
prisma.review.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
user: { select: { email: true, name: true } },
|
||||
product: { select: { title: true, slug: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.review.count({ where }),
|
||||
])
|
||||
|
||||
return NextResponse.json({ reviews, pagination: { page, limit, total, pages: Math.ceil(total / limit) } })
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { id, status } = body as { id: string; status: string }
|
||||
|
||||
const validStatuses = ['PENDING', 'APPROVED', 'HIDDEN']
|
||||
if (!id || !validStatuses.includes(status)) {
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
|
||||
}
|
||||
|
||||
const review = await prisma.review.update({
|
||||
where: { id },
|
||||
data: { status: status as 'PENDING' | 'APPROVED' | 'HIDDEN' },
|
||||
})
|
||||
|
||||
return NextResponse.json({ review })
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
|
||||
async function requireAdmin() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
||||
return user
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const settings = await prisma.siteSettings.findMany()
|
||||
|
||||
const settingsMap = settings.reduce(
|
||||
(acc, s) => {
|
||||
acc[s.key] = s.value
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
)
|
||||
|
||||
return NextResponse.json({ settings: settingsMap })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await requireAdmin()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { key, value } = body as { key: string; value: unknown }
|
||||
|
||||
if (!key) return NextResponse.json({ error: 'Key is required' }, { status: 400 })
|
||||
|
||||
const setting = await prisma.siteSettings.upsert({
|
||||
where: { key },
|
||||
update: { value: value as object },
|
||||
create: { key, value: value as object },
|
||||
})
|
||||
|
||||
return NextResponse.json({ setting })
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { adminUserSchema } from '@/lib/validate'
|
||||
import { hashPassword } from '@/lib/auth'
|
||||
|
||||
async function requireOwner() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || user.role !== 'OWNER') return null
|
||||
return user
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireOwner()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { role: { in: ['ADMIN', 'OWNER'] } },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
return NextResponse.json({ users })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await requireOwner()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = adminUserSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { email, name, role, password } = parsed.data
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } })
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Email already in use' }, { status: 409 })
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password)
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
role: role as 'ADMIN' | 'OWNER',
|
||||
passwordHash,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||
})
|
||||
|
||||
return NextResponse.json({ user: newUser }, { status: 201 })
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const user = await requireOwner()
|
||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get('id')
|
||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
|
||||
|
||||
if (id === user.id) {
|
||||
return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 400 })
|
||||
}
|
||||
|
||||
await prisma.user.delete({ where: { id } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser, verifyPassword, hashPassword } from '@/lib/auth'
|
||||
import { changePasswordSchema } from '@/lib/validate'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = changePasswordSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = parsed.data
|
||||
|
||||
const valid = await verifyPassword(currentPassword, user.passwordHash)
|
||||
if (!valid) {
|
||||
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 })
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(newPassword)
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash, mustChangePassword: false },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
verifyPassword,
|
||||
createSession,
|
||||
setSessionCookie,
|
||||
} from '@/lib/auth'
|
||||
import { loginSchema } from '@/lib/validate'
|
||||
|
||||
// Simple in-memory rate limiter
|
||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now()
|
||||
const windowMs = 15 * 60 * 1000 // 15 minutes
|
||||
const maxAttempts = 10
|
||||
|
||||
const record = loginAttempts.get(ip)
|
||||
if (!record || record.resetAt < now) {
|
||||
loginAttempts.set(ip, { count: 1, resetAt: now + windowMs })
|
||||
return true
|
||||
}
|
||||
|
||||
if (record.count >= maxAttempts) {
|
||||
return false
|
||||
}
|
||||
|
||||
record.count++
|
||||
return true
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = request.headers.get('x-forwarded-for') || 'unknown'
|
||||
|
||||
if (!checkRateLimit(ip)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many login attempts. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = loginSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { email, password } = parsed.data
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } })
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
|
||||
}
|
||||
|
||||
const token = await createSession(user.id)
|
||||
await setSessionCookie(token)
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSessionToken, deleteSession, clearSessionCookie } from '@/lib/auth'
|
||||
|
||||
export async function POST() {
|
||||
const token = await getSessionToken()
|
||||
|
||||
if (token) {
|
||||
await deleteSession(token)
|
||||
await clearSessionCookie()
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { hashPassword, createSession, setSessionCookie } from '@/lib/auth'
|
||||
import { registerSchema } from '@/lib/validate'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = registerSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { email, password, name } = parsed.data
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } })
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Email already in use' }, { status: 409 })
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password)
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
name,
|
||||
role: 'CUSTOMER',
|
||||
},
|
||||
})
|
||||
|
||||
const token = await createSession(user.id)
|
||||
await setSessionCookie(token)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
},
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { items } = body as { items: Array<{ productId: string; quantity: number; variantId?: string }> }
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return NextResponse.json({ error: 'No items provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const productIds = items.map((i) => i.productId)
|
||||
const products = await prisma.product.findMany({
|
||||
where: {
|
||||
id: { in: productIds },
|
||||
status: 'PUBLISHED',
|
||||
},
|
||||
include: {
|
||||
variants: { where: { active: true } },
|
||||
images: { take: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const cartItems = items
|
||||
.map((item) => {
|
||||
const product = products.find((p) => p.id === item.productId)
|
||||
if (!product) return null
|
||||
|
||||
let price = product.basePrice
|
||||
let variantInfo = null
|
||||
|
||||
if (item.variantId) {
|
||||
const variant = product.variants.find((v) => v.id === item.variantId)
|
||||
if (variant) {
|
||||
price = variant.price
|
||||
variantInfo = { id: variant.id, sku: variant.sku, attributes: variant.attributes }
|
||||
}
|
||||
}
|
||||
|
||||
const availableStock =
|
||||
item.variantId
|
||||
? product.variants.find((v) => v.id === item.variantId)?.stock ?? 0
|
||||
: product.stock ?? Infinity
|
||||
|
||||
const quantity = Math.min(item.quantity, availableStock)
|
||||
|
||||
return {
|
||||
productId: product.id,
|
||||
title: product.title,
|
||||
slug: product.slug,
|
||||
price,
|
||||
quantity,
|
||||
totalPrice: price * quantity,
|
||||
image: product.images[0]?.url || null,
|
||||
variant: variantInfo,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const subtotal = cartItems.reduce((sum, item) => sum + (item?.totalPrice ?? 0), 0)
|
||||
|
||||
return NextResponse.json({ items: cartItems, subtotal })
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { createCheckoutSession } from '@/lib/stripe'
|
||||
import { checkoutSchema } from '@/lib/validate'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = checkoutSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { items } = parsed.data
|
||||
|
||||
const productIds = items.map((i) => i.productId)
|
||||
const products = await prisma.product.findMany({
|
||||
where: {
|
||||
id: { in: productIds },
|
||||
status: 'PUBLISHED',
|
||||
},
|
||||
include: { variants: { where: { active: true } } },
|
||||
})
|
||||
|
||||
type OrderItemInput = {
|
||||
product: { connect: { id: string } }
|
||||
title: string
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
totalPrice: number
|
||||
metadata?: { variantId: string; sku: string }
|
||||
}
|
||||
const orderItems: OrderItemInput[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const product = products.find((p: typeof products[number]) => p.id === item.productId)
|
||||
if (!product) {
|
||||
return NextResponse.json(
|
||||
{ error: `Product ${item.productId} not found` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let price = product.basePrice
|
||||
let metadata: { variantId: string; sku: string } | undefined
|
||||
|
||||
if (item.variantId) {
|
||||
const variant = product.variants.find((v: typeof product.variants[number]) => v.id === item.variantId)
|
||||
if (variant) {
|
||||
price = variant.price
|
||||
metadata = { variantId: variant.id, sku: variant.sku }
|
||||
}
|
||||
}
|
||||
|
||||
orderItems.push({
|
||||
product: { connect: { id: product.id } },
|
||||
title: product.title,
|
||||
quantity: item.quantity,
|
||||
unitPrice: price,
|
||||
totalPrice: price * item.quantity,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
const subtotal = orderItems.reduce((sum, i) => sum + i.totalPrice, 0)
|
||||
const grandTotal = subtotal
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
status: 'PENDING',
|
||||
currency: 'EUR',
|
||||
subtotal,
|
||||
grandTotal,
|
||||
items: {
|
||||
create: orderItems,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const stripeLineItems = orderItems.map((item) => ({
|
||||
price_data: {
|
||||
currency: 'eur',
|
||||
product_data: { name: item.title },
|
||||
unit_amount: item.unitPrice,
|
||||
},
|
||||
quantity: item.quantity,
|
||||
}))
|
||||
|
||||
const appUrl = process.env.APP_URL || 'http://localhost'
|
||||
const checkoutSession = await createCheckoutSession({
|
||||
orderId: order.id,
|
||||
lineItems: stripeLineItems,
|
||||
customerEmail: user.email,
|
||||
successUrl: `${appUrl}/account/orders?success=1&orderId=${order.id}`,
|
||||
cancelUrl: `${appUrl}/cart`,
|
||||
})
|
||||
|
||||
await prisma.payment.create({
|
||||
data: {
|
||||
orderId: order.id,
|
||||
provider: 'stripe',
|
||||
providerPaymentId: checkoutSession.id,
|
||||
status: 'pending',
|
||||
amount: grandTotal,
|
||||
currency: 'EUR',
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url, orderId: order.id })
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId: user.id },
|
||||
include: {
|
||||
items: true,
|
||||
payment: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
return NextResponse.json({ orders })
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { slug: string } }
|
||||
) {
|
||||
const product = await prisma.product.findFirst({
|
||||
where: {
|
||||
slug: params.slug,
|
||||
status: 'PUBLISHED',
|
||||
},
|
||||
include: {
|
||||
images: true,
|
||||
categories: { include: { category: true } },
|
||||
type: true,
|
||||
variants: { where: { active: true } },
|
||||
reviews: {
|
||||
where: { status: 'APPROVED' },
|
||||
include: { user: { select: { name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!product) {
|
||||
return NextResponse.json({ error: 'Product not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ product })
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const category = searchParams.get('category')
|
||||
const search = searchParams.get('search')
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
status: 'PUBLISHED',
|
||||
}
|
||||
|
||||
if (category) {
|
||||
where.categories = {
|
||||
some: {
|
||||
category: { slug: category },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const [products, total] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
images: { take: 1 },
|
||||
categories: { include: { category: true } },
|
||||
type: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.product.count({ where }),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
products,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { reviewSchema } from '@/lib/validate'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = reviewSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message || 'Invalid input' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { productId, rating, title, comment } = parsed.data
|
||||
|
||||
const product = await prisma.product.findUnique({ where: { id: productId } })
|
||||
if (!product) {
|
||||
return NextResponse.json({ error: 'Product not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const existing = await prisma.review.findUnique({
|
||||
where: { userId_productId: { userId: user.id, productId } },
|
||||
})
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'You have already reviewed this product' }, { status: 409 })
|
||||
}
|
||||
|
||||
// Check if the user has purchased this product
|
||||
const hasPurchased = await prisma.orderItem.findFirst({
|
||||
where: {
|
||||
productId,
|
||||
order: { userId: user.id, status: { in: ['PAID', 'FULFILLED'] } },
|
||||
},
|
||||
})
|
||||
|
||||
const review = await prisma.review.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
productId,
|
||||
rating,
|
||||
title,
|
||||
comment,
|
||||
verified: !!hasPurchased,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ review }, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { constructWebhookEvent } from '@/lib/stripe'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendOrderConfirmationEmail } from '@/lib/email'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.text()
|
||||
const signature = request.headers.get('stripe-signature')
|
||||
|
||||
if (!signature) {
|
||||
return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 })
|
||||
}
|
||||
|
||||
let event: Stripe.Event
|
||||
try {
|
||||
event = constructWebhookEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
|
||||
} catch (err) {
|
||||
console.error('Webhook signature verification failed:', err)
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as Stripe.Checkout.Session
|
||||
const orderId = session.metadata?.orderId
|
||||
|
||||
if (!orderId) break
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: 'PAID' },
|
||||
})
|
||||
|
||||
await prisma.payment.updateMany({
|
||||
where: { orderId },
|
||||
data: {
|
||||
status: 'paid',
|
||||
providerPaymentId: session.payment_intent as string,
|
||||
rawPayload: event.data.object as object,
|
||||
},
|
||||
})
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { user: true },
|
||||
})
|
||||
|
||||
if (order?.user?.email) {
|
||||
try {
|
||||
await sendOrderConfirmationEmail(order.user.email, {
|
||||
orderId: order.id,
|
||||
grandTotal: order.grandTotal,
|
||||
currency: order.currency,
|
||||
})
|
||||
} catch (emailErr) {
|
||||
console.error('Failed to send confirmation email:', emailErr)
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'payment_intent.succeeded': {
|
||||
const paymentIntent = event.data.object as Stripe.PaymentIntent
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: { providerPaymentId: paymentIntent.id },
|
||||
})
|
||||
|
||||
if (payment) {
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: {
|
||||
status: 'paid',
|
||||
rawPayload: event.data.object as object,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: payment.orderId },
|
||||
data: { status: 'PAID' },
|
||||
})
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'payment_intent.payment_failed': {
|
||||
const paymentIntent = event.data.object as Stripe.PaymentIntent
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: { providerPaymentId: paymentIntent.id },
|
||||
})
|
||||
|
||||
if (payment) {
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
rawPayload: event.data.object as object,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: payment.orderId },
|
||||
data: { status: 'CANCELLED' },
|
||||
})
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing webhook:', err)
|
||||
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true })
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Navbar } from '@/components/storefront/Navbar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
|
||||
interface CartItem {
|
||||
productId: string
|
||||
title: string
|
||||
price: number
|
||||
quantity: number
|
||||
variantId?: string
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
export default function CartPage() {
|
||||
const [cart, setCart] = useState<CartItem[]>([])
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||
setCart(stored)
|
||||
}, [])
|
||||
|
||||
function updateQuantity(index: number, qty: number) {
|
||||
const newCart = [...cart]
|
||||
if (qty <= 0) {
|
||||
newCart.splice(index, 1)
|
||||
} else {
|
||||
newCart[index] = { ...newCart[index], quantity: qty }
|
||||
}
|
||||
setCart(newCart)
|
||||
localStorage.setItem('cart', JSON.stringify(newCart))
|
||||
window.dispatchEvent(new Event('storage'))
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
const newCart = cart.filter((_, i) => i !== index)
|
||||
setCart(newCart)
|
||||
localStorage.setItem('cart', JSON.stringify(newCart))
|
||||
window.dispatchEvent(new Event('storage'))
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
setCart([])
|
||||
localStorage.removeItem('cart')
|
||||
window.dispatchEvent(new Event('storage'))
|
||||
}
|
||||
|
||||
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
|
||||
async function handleCheckout() {
|
||||
const user = localStorage.getItem('user')
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/cart')
|
||||
return
|
||||
}
|
||||
router.push('/checkout')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Shopping Cart</h1>
|
||||
|
||||
{cart.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-500 mb-4">Your cart is empty</p>
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-block bg-blue-600 text-white px-6 py-3 rounded hover:bg-blue-700"
|
||||
>
|
||||
Continue Shopping
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg border border-gray-200 divide-y">
|
||||
{cart.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-4 p-4">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded flex-shrink-0">
|
||||
{item.image ? (
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
|
||||
No img
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{item.title}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{(item.price / 100).toFixed(2)} EUR each
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateQuantity(index, item.quantity - 1)}
|
||||
className="w-7 h-7 rounded border flex items-center justify-center hover:bg-gray-50"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-8 text-center text-sm">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(index, item.quantity + 1)}
|
||||
className="w-7 h-7 rounded border flex items-center justify-center hover:bg-gray-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<p className="w-20 text-right font-medium">
|
||||
{(item.price * item.quantity / 100).toFixed(2)} EUR
|
||||
</p>
|
||||
<button
|
||||
onClick={() => removeItem(index)}
|
||||
className="text-gray-400 hover:text-red-500 ml-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={clearCart}
|
||||
className="mt-3 text-sm text-gray-500 hover:text-red-500"
|
||||
>
|
||||
Clear cart
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="font-semibold text-lg mb-4">Order Summary</h2>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Subtotal</span>
|
||||
<span>{(subtotal / 100).toFixed(2)} EUR</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Shipping</span>
|
||||
<span className="text-gray-500">Calculated at checkout</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-4 mb-6">
|
||||
<div className="flex justify-between font-bold">
|
||||
<span>Total</span>
|
||||
<span>{(subtotal / 100).toFixed(2)} EUR</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" size="lg" onClick={handleCheckout}>
|
||||
Proceed to Checkout
|
||||
</Button>
|
||||
<Link
|
||||
href="/products"
|
||||
className="block text-center mt-3 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Continue Shopping
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Navbar } from '@/components/storefront/Navbar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
interface CartItem {
|
||||
productId: string
|
||||
title: string
|
||||
price: number
|
||||
quantity: number
|
||||
variantId?: string
|
||||
}
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const [cart, setCart] = useState<CartItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||
if (stored.length === 0) {
|
||||
router.push('/cart')
|
||||
return
|
||||
}
|
||||
setCart(stored)
|
||||
|
||||
const user = localStorage.getItem('user')
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/checkout')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
|
||||
async function handleCheckout() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
items: cart.map((item) => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
variantId: item.variantId,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Checkout failed')
|
||||
return
|
||||
}
|
||||
|
||||
// Clear cart after successful order creation
|
||||
localStorage.removeItem('cart')
|
||||
window.dispatchEvent(new Event('storage'))
|
||||
|
||||
// Redirect to Stripe checkout
|
||||
if (data.url) {
|
||||
window.location.href = data.url
|
||||
} else {
|
||||
router.push(`/account/orders?orderId=${data.orderId}`)
|
||||
}
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<main className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Checkout</h1>
|
||||
|
||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 mb-6">
|
||||
<div className="p-6">
|
||||
<h2 className="font-semibold mb-4">Order Summary</h2>
|
||||
<div className="space-y-3">
|
||||
{cart.map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-sm">
|
||||
<span>
|
||||
{item.title} × {item.quantity}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{((item.price * item.quantity) / 100).toFixed(2)} EUR
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t mt-4 pt-4">
|
||||
<div className="flex justify-between font-bold">
|
||||
<span>Total</span>
|
||||
<span>{(subtotal / 100).toFixed(2)} EUR</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6 text-sm text-blue-800">
|
||||
<p className="font-medium mb-1">Secure Payment via Stripe</p>
|
||||
<p>You will be redirected to Stripe to complete your payment securely.</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCheckout}
|
||||
loading={loading}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? 'Processing...' : `Pay ${(subtotal / 100).toFixed(2)} EUR`}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/cart')}
|
||||
className="block w-full text-center mt-3 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Back to Cart
|
||||
</button>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'ShopX - E-Commerce Platform',
|
||||
description: 'Your online store',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-gray-50 text-gray-900 min-h-screen">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirect = searchParams.get('redirect') || '/'
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Login failed')
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem('user', JSON.stringify(data.user))
|
||||
|
||||
if (data.user.mustChangePassword) {
|
||||
router.push('/admin/change-password')
|
||||
} else if (data.user.role === 'ADMIN' || data.user.role === 'OWNER') {
|
||||
router.push('/admin')
|
||||
} else {
|
||||
router.push(redirect)
|
||||
}
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="text-2xl font-bold text-gray-900">ShopX</Link>
|
||||
<h1 className="text-xl font-semibold mt-4">Welcome back</h1>
|
||||
<p className="text-gray-600 text-sm mt-1">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<Button type="submit" loading={loading} className="w-full" size="lg">
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-600 mt-6">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="text-blue-600 hover:underline">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { Navbar } from '@/components/storefront/Navbar'
|
||||
import { ProductCard } from '@/components/storefront/ProductCard'
|
||||
|
||||
async function getFeaturedProducts() {
|
||||
return prisma.product.findMany({
|
||||
where: { status: 'PUBLISHED' },
|
||||
take: 8,
|
||||
include: { images: { take: 1 } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const products = await getFeaturedProducts()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="bg-blue-600 text-white py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Welcome to ShopX</h1>
|
||||
<p className="text-xl text-blue-100 mb-8">
|
||||
Discover our curated collection of products
|
||||
</p>
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-block bg-white text-blue-600 font-semibold px-8 py-3 rounded-lg hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
Shop Now
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Products */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<h2 className="text-2xl font-bold mb-8">Featured Products</h2>
|
||||
{products.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
<p>No products available yet.</p>
|
||||
<p className="mt-2 text-sm">Check back soon or add products in the admin panel.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{products.map((product: typeof products[number]) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{products.length > 0 && (
|
||||
<div className="text-center mt-10">
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
View All Products
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="bg-white py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
|
||||
<div className="p-6">
|
||||
<div className="text-3xl mb-3">🚚</div>
|
||||
<h3 className="font-semibold mb-2">Fast Shipping</h3>
|
||||
<p className="text-gray-600 text-sm">Get your orders delivered quickly</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="text-3xl mb-3">🔒</div>
|
||||
<h3 className="font-semibold mb-2">Secure Payments</h3>
|
||||
<p className="text-gray-600 text-sm">Powered by Stripe for safe transactions</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="text-3xl mb-3">↩️</div>
|
||||
<h3 className="font-semibold mb-2">Easy Returns</h3>
|
||||
<p className="text-gray-600 text-sm">30-day hassle-free return policy</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="bg-gray-800 text-gray-300 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm">
|
||||
<p>© {new Date().getFullYear()} ShopX. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Navbar } from '@/components/storefront/Navbar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
description: string
|
||||
basePrice: number
|
||||
currency: string
|
||||
stock: number | null
|
||||
images: Array<{ url: string; altText?: string | null }>
|
||||
variants: Array<{ id: string; sku: string; price: number; stock: number; attributes: Record<string, unknown> }>
|
||||
categories: Array<{ category: { name: string; slug: string } }>
|
||||
reviews: Array<{ id: string; rating: number; title?: string | null; comment?: string | null; user: { name: string | null }; createdAt: string }>
|
||||
}
|
||||
|
||||
export default function ProductDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const [product, setProduct] = useState<Product | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedVariant, setSelectedVariant] = useState<string | null>(null)
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const [addedToCart, setAddedToCart] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/products/${params.slug}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.product) {
|
||||
setProduct(data.product)
|
||||
if (data.product.variants.length > 0) {
|
||||
setSelectedVariant(data.product.variants[0].id)
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to load product')
|
||||
setLoading(false)
|
||||
})
|
||||
}, [params.slug])
|
||||
|
||||
function addToCart() {
|
||||
if (!product) return
|
||||
|
||||
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||
const existingIndex = cart.findIndex(
|
||||
(item: { productId: string; variantId?: string }) =>
|
||||
item.productId === product.id && item.variantId === selectedVariant
|
||||
)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
cart[existingIndex].quantity += quantity
|
||||
} else {
|
||||
cart.push({
|
||||
productId: product.id,
|
||||
title: product.title,
|
||||
price: selectedVariant
|
||||
? product.variants.find((v) => v.id === selectedVariant)?.price || product.basePrice
|
||||
: product.basePrice,
|
||||
quantity,
|
||||
variantId: selectedVariant || undefined,
|
||||
image: product.images[0]?.url || null,
|
||||
})
|
||||
}
|
||||
|
||||
localStorage.setItem('cart', JSON.stringify(cart))
|
||||
setAddedToCart(true)
|
||||
setTimeout(() => setAddedToCart(false), 3000)
|
||||
|
||||
// Trigger storage event for Navbar cart count update
|
||||
window.dispatchEvent(new Event('storage'))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="h-64 bg-gray-200 rounded mb-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!product || error) {
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="max-w-7xl mx-auto px-4 py-8 text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Product Not Found</h1>
|
||||
<Button onClick={() => router.push('/products')}>Back to Products</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const displayPrice = selectedVariant
|
||||
? product.variants.find((v) => v.id === selectedVariant)?.price || product.basePrice
|
||||
: product.basePrice
|
||||
|
||||
const avgRating =
|
||||
product.reviews.length > 0
|
||||
? product.reviews.reduce((sum, r) => sum + r.rating, 0) / product.reviews.length
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
|
||||
{/* Images */}
|
||||
<div>
|
||||
<div className="aspect-square bg-gray-100 rounded-lg overflow-hidden mb-4">
|
||||
{product.images[0] ? (
|
||||
<img
|
||||
src={product.images[0].url}
|
||||
alt={product.images[0].altText || product.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
No image available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{product.images.length > 1 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{product.images.slice(1).map((img, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={img.url}
|
||||
alt={img.altText || `${product.title} ${i + 2}`}
|
||||
className="aspect-square object-cover rounded border border-gray-200"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{product.categories.map((c) => (
|
||||
<span
|
||||
key={c.category.slug}
|
||||
className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"
|
||||
>
|
||||
{c.category.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold mb-4">{product.title}</h1>
|
||||
|
||||
{product.reviews.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<span
|
||||
key={star}
|
||||
className={star <= avgRating ? 'text-yellow-400' : 'text-gray-300'}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{avgRating.toFixed(1)} ({product.reviews.length} reviews)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-3xl font-bold text-gray-900 mb-6">
|
||||
{(displayPrice / 100).toFixed(2)} {product.currency}
|
||||
</p>
|
||||
|
||||
<p className="text-gray-600 mb-6">{product.description}</p>
|
||||
|
||||
{/* Variants */}
|
||||
{product.variants.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium mb-2">Options</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.variants.map((variant) => (
|
||||
<button
|
||||
key={variant.id}
|
||||
onClick={() => setSelectedVariant(variant.id)}
|
||||
disabled={variant.stock === 0}
|
||||
className={`px-3 py-1.5 rounded border text-sm ${
|
||||
selectedVariant === variant.id
|
||||
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
} ${variant.stock === 0 ? 'opacity-50 cursor-not-allowed line-through' : ''}`}
|
||||
>
|
||||
{variant.sku}
|
||||
{variant.stock === 0 && ' (Out of stock)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium mb-2">Quantity</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="w-8 h-8 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-50"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-12 text-center font-medium">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
className="w-8 h-8 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{addedToCart && (
|
||||
<Alert variant="success" className="mb-4">
|
||||
Added to cart!{' '}
|
||||
<a href="/cart" className="underline">
|
||||
View cart
|
||||
</a>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button size="lg" onClick={addToCart} className="flex-1">
|
||||
Add to Cart
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
addToCart()
|
||||
router.push('/cart')
|
||||
}}
|
||||
>
|
||||
Buy Now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{product.stock !== null && product.stock <= 5 && product.stock > 0 && (
|
||||
<p className="mt-3 text-sm text-orange-600">
|
||||
Only {product.stock} left in stock!
|
||||
</p>
|
||||
)}
|
||||
{product.stock === 0 && (
|
||||
<p className="mt-3 text-sm text-red-600">Out of stock</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reviews */}
|
||||
{product.reviews.length > 0 && (
|
||||
<section className="mt-16">
|
||||
<h2 className="text-2xl font-bold mb-6">Customer Reviews</h2>
|
||||
<div className="space-y-4">
|
||||
{product.reviews.map((review) => (
|
||||
<div key={review.id} className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<span
|
||||
key={star}
|
||||
className={star <= review.rating ? 'text-yellow-400' : 'text-gray-300'}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{review.user.name || 'Customer'}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(review.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{review.title && <p className="font-medium mb-1">{review.title}</p>}
|
||||
{review.comment && <p className="text-gray-600 text-sm">{review.comment}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { Navbar } from '@/components/storefront/Navbar'
|
||||
import { ProductCard } from '@/components/storefront/ProductCard'
|
||||
import Link from 'next/link'
|
||||
|
||||
async function getProducts(searchParams: { category?: string; search?: string; page?: string }) {
|
||||
const page = parseInt(searchParams.page || '1')
|
||||
const limit = 20
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const where: Record<string, unknown> = { status: 'PUBLISHED' }
|
||||
|
||||
if (searchParams.category) {
|
||||
where.categories = {
|
||||
some: { category: { slug: searchParams.category } },
|
||||
}
|
||||
}
|
||||
|
||||
if (searchParams.search) {
|
||||
where.OR = [
|
||||
{ title: { contains: searchParams.search, mode: 'insensitive' } },
|
||||
{ description: { contains: searchParams.search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const [products, total, categories] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { images: { take: 1 } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.product.count({ where }),
|
||||
prisma.category.findMany({ orderBy: { name: 'asc' } }),
|
||||
])
|
||||
|
||||
return { products, total, categories, page, pages: Math.ceil(total / limit) }
|
||||
}
|
||||
|
||||
export default async function ProductsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { category?: string; search?: string; page?: string }
|
||||
}) {
|
||||
const { products, total, categories, page, pages } = await getProducts(searchParams)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-48 shrink-0">
|
||||
<h2 className="font-semibold mb-3">Categories</h2>
|
||||
<ul className="space-y-1">
|
||||
<li>
|
||||
<Link
|
||||
href="/products"
|
||||
className={`block text-sm py-1 px-2 rounded hover:bg-gray-100 ${
|
||||
!searchParams.category ? 'font-medium text-blue-600' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
All Products
|
||||
</Link>
|
||||
</li>
|
||||
{categories.map((cat: typeof categories[number]) => (
|
||||
<li key={cat.id}>
|
||||
<Link
|
||||
href={`/products?category=${cat.slug}`}
|
||||
className={`block text-sm py-1 px-2 rounded hover:bg-gray-100 ${
|
||||
searchParams.category === cat.slug
|
||||
? 'font-medium text-blue-600'
|
||||
: 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{searchParams.category
|
||||
? categories.find((c: typeof categories[number]) => c.slug === searchParams.category)?.name || 'Products'
|
||||
: 'All Products'}
|
||||
</h1>
|
||||
<span className="text-sm text-gray-500">{total} products</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<form className="mb-6">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
defaultValue={searchParams.search}
|
||||
placeholder="Search products..."
|
||||
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{searchParams.category && (
|
||||
<input type="hidden" name="category" value={searchParams.category} />
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
<p>No products found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{products.map((product: typeof products[number]) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pages > 1 && (
|
||||
<div className="mt-8 flex justify-center gap-2">
|
||||
{Array.from({ length: pages }, (_, i) => i + 1).map((p) => (
|
||||
<Link
|
||||
key={p}
|
||||
href={`/products?page=${p}${searchParams.category ? `&category=${searchParams.category}` : ''}${searchParams.search ? `&search=${searchParams.search}` : ''}`}
|
||||
className={`px-3 py-2 rounded text-sm ${
|
||||
p === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Registration failed')
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem('user', JSON.stringify(data.user))
|
||||
router.push('/')
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="text-2xl font-bold text-gray-900">ShopX</Link>
|
||||
<h1 className="text-xl font-semibold mt-4">Create Account</h1>
|
||||
<p className="text-gray-600 text-sm mt-1">Join ShopX today</p>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Full Name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Min 12 characters, uppercase, lowercase, number, and symbol.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" loading={loading} className="w-full" size="lg">
|
||||
Create Account
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-600 mt-6">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-blue-600 hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/admin', label: 'Dashboard', exact: true },
|
||||
{ href: '/admin/products', label: 'Products' },
|
||||
{ href: '/admin/product-types', label: 'Product Types' },
|
||||
{ href: '/admin/categories', label: 'Categories' },
|
||||
{ href: '/admin/orders', label: 'Orders' },
|
||||
{ href: '/admin/customers', label: 'Customers' },
|
||||
{ href: '/admin/reviews', label: 'Reviews' },
|
||||
{ href: '/admin/settings', label: 'Settings' },
|
||||
{ href: '/admin/admin-users', label: 'Admin Users' },
|
||||
]
|
||||
|
||||
export function AdminNav() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="bg-gray-900 text-white w-56 min-h-screen flex flex-col">
|
||||
<div className="px-4 py-5 border-b border-gray-700">
|
||||
<h1 className="text-lg font-bold">Admin Panel</h1>
|
||||
</div>
|
||||
<ul className="flex-1 py-4">
|
||||
{navItems.map((item) => {
|
||||
const active = item.exact ? pathname === item.href : pathname.startsWith(item.href)
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`block px-4 py-2 text-sm hover:bg-gray-700 transition-colors ${
|
||||
active ? 'bg-gray-700 text-white' : 'text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="px-4 py-4 border-t border-gray-700 space-y-2">
|
||||
<Link
|
||||
href="/admin/change-password"
|
||||
className="block text-sm text-gray-300 hover:text-white"
|
||||
>
|
||||
Change Password
|
||||
</Link>
|
||||
<Link href="/" className="block text-sm text-gray-300 hover:text-white">
|
||||
View Store
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block text-sm text-gray-300 hover:text-white w-full text-left"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export function Navbar() {
|
||||
const [cartCount, setCartCount] = useState(0)
|
||||
const [user, setUser] = useState<{ name?: string; email: string; role: string } | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||
const count = cart.reduce((sum: number, item: { quantity: number }) => sum + item.quantity, 0)
|
||||
setCartCount(count)
|
||||
|
||||
const userData = localStorage.getItem('user')
|
||||
if (userData) {
|
||||
try {
|
||||
setUser(JSON.parse(userData))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
localStorage.removeItem('user')
|
||||
setUser(null)
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<Link href="/" className="text-xl font-bold text-gray-900">
|
||||
ShopX
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link href="/products" className="text-gray-600 hover:text-gray-900">
|
||||
Products
|
||||
</Link>
|
||||
<Link href="/cart" className="text-gray-600 hover:text-gray-900 relative">
|
||||
Cart
|
||||
{cartCount > 0 && (
|
||||
<span className="ml-1 bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
{user ? (
|
||||
<>
|
||||
<Link href="/account" className="text-gray-600 hover:text-gray-900">
|
||||
Account
|
||||
</Link>
|
||||
{(user.role === 'ADMIN' || user.role === 'OWNER') && (
|
||||
<Link href="/admin" className="text-gray-600 hover:text-gray-900">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login" className="text-gray-600 hover:text-gray-900">
|
||||
Login
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
basePrice: number
|
||||
currency: string
|
||||
images: Array<{ url: string; altText?: string | null }>
|
||||
}
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product
|
||||
}
|
||||
|
||||
export function ProductCard({ product }: ProductCardProps) {
|
||||
const price = (product.basePrice / 100).toFixed(2)
|
||||
const image = product.images[0]
|
||||
|
||||
return (
|
||||
<Link href={`/products/${product.slug}`} className="group">
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-md transition-shadow">
|
||||
<div className="aspect-square bg-gray-100 flex items-center justify-center">
|
||||
{image ? (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText || product.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm">No image</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-medium text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2">
|
||||
{product.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-lg font-bold text-gray-900">
|
||||
{price} {product.currency}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
interface AlertProps {
|
||||
children: React.ReactNode
|
||||
variant?: 'info' | 'success' | 'warning' | 'error'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Alert({ children, variant = 'info', className = '' }: AlertProps) {
|
||||
const variants = {
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
success: 'bg-green-50 border-green-200 text-green-800',
|
||||
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||||
error: 'bg-red-50 border-red-200 text-red-800',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded border px-4 py-3 text-sm ${variants[variant]} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info'
|
||||
}
|
||||
|
||||
export function Badge({ children, variant = 'default' }: BadgeProps) {
|
||||
const variants = {
|
||||
default: 'bg-gray-100 text-gray-700',
|
||||
success: 'bg-green-100 text-green-700',
|
||||
warning: 'bg-yellow-100 text-yellow-700',
|
||||
danger: 'bg-red-100 text-red-700',
|
||||
info: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${variants[variant]}`}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled,
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const base = 'inline-flex items-center justify-center font-medium rounded focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
|
||||
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-400',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
ghost: 'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-400',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
interface CardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Card({ children, className = '' }: CardProps) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border border-gray-200 shadow-sm ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader({ children, className = '' }: CardProps) {
|
||||
return (
|
||||
<div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardBody({ children, className = '' }: CardProps) {
|
||||
return (
|
||||
<div className={`px-6 py-4 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react'
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function Input({ label, error, className = '', id, ...props }: InputProps) {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
className={`block w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
error ? 'border-red-500' : 'border-gray-300'
|
||||
} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function Textarea({ label, error, className = '', id, ...props }: TextareaProps) {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
id={inputId}
|
||||
className={`block w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
error ? 'border-red-500' : 'border-gray-300'
|
||||
} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function Select({ label, error, className = '', id, children, ...props }: SelectProps) {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={inputId}
|
||||
className={`block w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
error ? 'border-red-500' : 'border-gray-300'
|
||||
} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { prisma } from './prisma'
|
||||
import type { User, Session } from '@prisma/client'
|
||||
|
||||
const COOKIE_NAME = 'session_token'
|
||||
const SESSION_EXPIRY_DAYS = 30
|
||||
const BCRYPT_COST = 12
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, BCRYPT_COST)
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash)
|
||||
}
|
||||
|
||||
export function validatePasswordStrength(password: string): string | null {
|
||||
if (password.length < 12) return 'Password must be at least 12 characters'
|
||||
if (!/[A-Z]/.test(password)) return 'Password must contain at least one uppercase letter'
|
||||
if (!/[a-z]/.test(password)) return 'Password must contain at least one lowercase letter'
|
||||
if (!/[0-9]/.test(password)) return 'Password must contain at least one number'
|
||||
if (!/[^A-Za-z0-9]/.test(password)) return 'Password must contain at least one symbol'
|
||||
return null
|
||||
}
|
||||
|
||||
export async function createSession(userId: string): Promise<string> {
|
||||
const token = randomBytes(32).toString('hex')
|
||||
const tokenHash = hashToken(token)
|
||||
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000)
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
},
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
export async function setSessionCookie(token: string): Promise<void> {
|
||||
const cookieStore = cookies()
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
cookieStore.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: isProd,
|
||||
sameSite: 'lax',
|
||||
expires: new Date(Date.now() + SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000),
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearSessionCookie(): Promise<void> {
|
||||
const cookieStore = cookies()
|
||||
cookieStore.delete(COOKIE_NAME)
|
||||
}
|
||||
|
||||
export async function getSessionToken(): Promise<string | null> {
|
||||
const cookieStore = cookies()
|
||||
const cookie = cookieStore.get(COOKIE_NAME)
|
||||
return cookie?.value ?? null
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<(Session & { user: User }) | null> {
|
||||
const token = await getSessionToken()
|
||||
if (!token) return null
|
||||
|
||||
const tokenHash = hashToken(token)
|
||||
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { tokenHash },
|
||||
include: { user: true },
|
||||
})
|
||||
|
||||
if (!session) return null
|
||||
if (session.expiresAt < new Date()) {
|
||||
await prisma.session.delete({ where: { id: session.id } })
|
||||
return null
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
const session = await getSession()
|
||||
return session?.user ?? null
|
||||
}
|
||||
|
||||
export async function deleteSession(token: string): Promise<void> {
|
||||
const tokenHash = hashToken(token)
|
||||
await prisma.session.deleteMany({ where: { tokenHash } })
|
||||
}
|
||||
|
||||
export async function deleteAllUserSessions(userId: string): Promise<void> {
|
||||
await prisma.session.deleteMany({ where: { userId } })
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'localhost',
|
||||
port: parseInt(process.env.SMTP_PORT || '1025'),
|
||||
secure: false,
|
||||
auth:
|
||||
process.env.SMTP_USER
|
||||
? {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
export async function sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
}: {
|
||||
to: string
|
||||
subject: string
|
||||
html: string
|
||||
text?: string
|
||||
}): Promise<void> {
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || 'noreply@localhost',
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendOrderConfirmationEmail(
|
||||
to: string,
|
||||
orderDetails: { orderId: string; grandTotal: number; currency: string }
|
||||
): Promise<void> {
|
||||
const amount = (orderDetails.grandTotal / 100).toFixed(2)
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: `Order Confirmation #${orderDetails.orderId}`,
|
||||
html: `
|
||||
<h1>Thank you for your order!</h1>
|
||||
<p>Your order <strong>#${orderDetails.orderId}</strong> has been confirmed.</p>
|
||||
<p>Total: <strong>${amount} ${orderDetails.currency}</strong></p>
|
||||
<p>You can track your order in your <a href="${process.env.APP_URL}/account/orders">account</a>.</p>
|
||||
`,
|
||||
text: `Order #${orderDetails.orderId} confirmed. Total: ${amount} ${orderDetails.currency}`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(
|
||||
to: string,
|
||||
resetToken: string
|
||||
): Promise<void> {
|
||||
const resetUrl = `${process.env.APP_URL}/reset-password?token=${resetToken}`
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: 'Password Reset Request',
|
||||
html: `
|
||||
<h1>Password Reset</h1>
|
||||
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
|
||||
<a href="${resetUrl}">${resetUrl}</a>
|
||||
<p>If you did not request a password reset, please ignore this email.</p>
|
||||
`,
|
||||
text: `Reset your password: ${resetUrl}`,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
@@ -0,0 +1,39 @@
|
||||
import Stripe from 'stripe'
|
||||
|
||||
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: '2024-06-20',
|
||||
})
|
||||
|
||||
export async function createCheckoutSession({
|
||||
orderId,
|
||||
lineItems,
|
||||
customerEmail,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
}: {
|
||||
orderId: string
|
||||
lineItems: Stripe.Checkout.SessionCreateParams.LineItem[]
|
||||
customerEmail: string
|
||||
successUrl: string
|
||||
cancelUrl: string
|
||||
}): Promise<Stripe.Checkout.Session> {
|
||||
return stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
mode: 'payment',
|
||||
customer_email: customerEmail,
|
||||
line_items: lineItems,
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
metadata: {
|
||||
orderId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function constructWebhookEvent(
|
||||
payload: string | Buffer,
|
||||
signature: string,
|
||||
secret: string
|
||||
): Stripe.Event {
|
||||
return stripe.webhooks.constructEvent(payload, signature, secret)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
})
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z
|
||||
.string()
|
||||
.min(12, 'Password must be at least 12 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one symbol'),
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
})
|
||||
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, 'Current password is required'),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(12, 'Password must be at least 12 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one symbol'),
|
||||
})
|
||||
|
||||
export const productTypeSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, 'Slug is required')
|
||||
.max(100)
|
||||
.regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens'),
|
||||
schema: z.record(z.any()),
|
||||
})
|
||||
|
||||
export const productSchema = z.object({
|
||||
typeId: z.string().min(1, 'Product type is required'),
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, 'Slug is required')
|
||||
.max(200)
|
||||
.regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
basePrice: z.number().int().min(0, 'Price must be non-negative'),
|
||||
currency: z.string().length(3, 'Currency must be 3 characters'),
|
||||
status: z.enum(['DRAFT', 'PUBLISHED', 'ARCHIVED']),
|
||||
attributes: z.record(z.any()),
|
||||
stock: z.number().int().min(0).nullable().optional(),
|
||||
categoryIds: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const categorySchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, 'Slug is required')
|
||||
.max(100)
|
||||
.regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens'),
|
||||
parentId: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export const reviewSchema = z.object({
|
||||
productId: z.string().min(1, 'Product ID is required'),
|
||||
rating: z.number().int().min(1).max(5),
|
||||
title: z.string().max(200).optional(),
|
||||
comment: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
export const cartItemSchema = z.object({
|
||||
productId: z.string().min(1),
|
||||
quantity: z.number().int().min(1).max(100),
|
||||
variantId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const checkoutSchema = z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
productId: z.string(),
|
||||
quantity: z.number().int().min(1),
|
||||
variantId: z.string().optional(),
|
||||
})
|
||||
).min(1, 'Cart is empty'),
|
||||
})
|
||||
|
||||
export const settingSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
value: z.any(),
|
||||
})
|
||||
|
||||
export const adminUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1).max(100),
|
||||
role: z.enum(['ADMIN', 'OWNER']),
|
||||
password: z
|
||||
.string()
|
||||
.min(12, 'Password must be at least 12 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one symbol'),
|
||||
})
|
||||
|
||||
export type LoginInput = z.infer<typeof loginSchema>
|
||||
export type RegisterInput = z.infer<typeof registerSchema>
|
||||
export type ProductTypeInput = z.infer<typeof productTypeSchema>
|
||||
export type ProductInput = z.infer<typeof productSchema>
|
||||
export type CategoryInput = z.infer<typeof categorySchema>
|
||||
export type ReviewInput = z.infer<typeof reviewSchema>
|
||||
export type CheckoutInput = z.infer<typeof checkoutSchema>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const response = NextResponse.next()
|
||||
response.headers.set('x-pathname', request.nextUrl.pathname)
|
||||
return response
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,53 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ecommerce
|
||||
POSTGRES_PASSWORD: ecommerce_password
|
||||
POSTGRES_DB: ecommerce
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ecommerce"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
build:
|
||||
context: ./app
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://ecommerce:ecommerce_password@db:5432/ecommerce
|
||||
expose:
|
||||
- "3000"
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8025:8025"
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
Reference in New Issue
Block a user