Commit iniziale

This commit is contained in:
2026-05-18 15:25:38 +02:00
commit a8d4c158b8
79 changed files with 8730 additions and 0 deletions
+12
View File
@@ -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
View File
@@ -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/
+3
View File
@@ -0,0 +1,3 @@
localhost {
reverse_proxy app:3000
}
+334
View File
@@ -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 **510 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 510 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` |
+36
View File
@@ -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"]
+16
View File
@@ -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
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig
+2013
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"
}
}
+6
View File
@@ -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"
+257
View File
@@ -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())
}
+46
View File
@@ -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()
})
+46
View File
@@ -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()
})
+125
View File
@@ -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>
)
}
+80
View File
@@ -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>
)
}
+192
View File
@@ -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>
)
}
+188
View File
@@ -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>
)
}
+107
View File
@@ -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>
)
}
+68
View File
@@ -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>
)
}
+44
View File
@@ -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>
)
}
+223
View File
@@ -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>
)
}
+126
View File
@@ -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>
)
}
+158
View File
@@ -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>
)
}
+193
View File
@@ -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>
)
}
+306
View File
@@ -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>
)
}
+161
View File
@@ -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>
)
}
+137
View File
@@ -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>
)
}
+101
View File
@@ -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>
)
}
+78
View File
@@ -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 })
}
+47
View File
@@ -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 })
}
+40
View File
@@ -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 })
}
+97
View File
@@ -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 })
}
+65
View File
@@ -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 })
}
+50
View File
@@ -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 })
}
+90
View File
@@ -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 })
}
+81
View File
@@ -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,
},
})
}
+13
View File
@@ -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 })
}
+54
View File
@@ -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 }
)
}
+69
View File
@@ -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 })
}
+125
View File
@@ -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 })
}
+21
View File
@@ -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 })
}
+31
View File
@@ -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 })
}
+56
View File
@@ -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),
},
})
}
+61
View File
@@ -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 })
}
+124
View File
@@ -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 })
}
+174
View File
@@ -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>
)
}
+135
View File
@@ -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>
)
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+21
View File
@@ -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>
)
}
+108
View File
@@ -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&apos;t have an account?{' '}
<Link href="/register" className="text-blue-600 hover:underline">
Register
</Link>
</p>
</div>
</div>
</div>
)
}
+98
View File
@@ -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>&copy; {new Date().getFullYear()} ShopX. All rights reserved.</p>
</div>
</footer>
</div>
)
}
+306
View File
@@ -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>
)
}
+154
View File
@@ -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>
)
}
+105
View File
@@ -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>
)
}
+69
View File
@@ -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>
)
}
+90
View File
@@ -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>
)
}
+20
View File
@@ -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>
)
}
+22
View File
@@ -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>
)
}
+50
View File
@@ -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>
)
}
+28
View File
@@ -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>
)
}
+84
View File
@@ -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>
)
}
+103
View File
@@ -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 } })
}
+70
View File
@@ -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}`,
})
}
+13
View File
@@ -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
+39
View File
@@ -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)
}
+115
View File
@@ -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>
+12
View File
@@ -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).*)'],
}
+12
View File
@@ -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: [],
}
+27
View File
@@ -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
+53
View File
@@ -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: