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