Compare commits
8
Commits
b9ebd250e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1247608441 | ||
|
|
aac4ee6e43 | ||
|
|
126a74cddb | ||
|
|
982c268acc | ||
|
|
c966797073 | ||
|
|
8bfd7afdcb | ||
|
|
2526cf9543 | ||
|
|
c7d2713c23 |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"stripe@claude-plugins-official": true,
|
||||
"superpowers@claude-plugins-official": true,
|
||||
"security-guidance@claude-plugins-official": true,
|
||||
"ui-ux-pro-max@ui-ux-pro-max-skill": true,
|
||||
"claude-md-management@claude-plugins-official": true,
|
||||
"figma@claude-plugins-official": true,
|
||||
"frontend-design@claude-plugins-official":true
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -28,11 +28,11 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Dati locali (bind mount Docker)
|
||||
data/
|
||||
|
||||
# Uploads generati a runtime (volume Docker)
|
||||
app/public/uploads/
|
||||
# Dati locali (bind mount Docker) — ignora contenuto, traccia solo struttura
|
||||
data/db/
|
||||
data/caddy/
|
||||
data/uploads/*
|
||||
!data/uploads/.gitkeep
|
||||
|
||||
# Backup
|
||||
backups/
|
||||
|
||||
@@ -12,6 +12,7 @@ Piattaforma e-commerce containerizzata, avviabile con un singolo comando.
|
||||
|
||||
- [Docker Desktop](https://docs.docker.com/get-docker/) installato e avviato
|
||||
- Porta **80** libera (nessun altro web server in esecuzione)
|
||||
- Account [Stripe](https://stripe.com) gratuito (necessario per i pagamenti)
|
||||
|
||||
### 1. Clona il repository
|
||||
|
||||
@@ -26,14 +27,28 @@ cd ecommerce-platform
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Il file `.env` di default è già configurato per localhost. **Non serve modificare nulla** per i primi test.
|
||||
Apri `.env` e inserisci la tua chiave Stripe test (gratuita, dalla [Stripe Dashboard → API keys](https://dashboard.stripe.com/test/apikeys)):
|
||||
|
||||
```env
|
||||
STRIPE_SECRET_KEY=sk_test_la_tua_chiave
|
||||
```
|
||||
|
||||
Il resto dei valori è già preconfigurato per localhost e non va modificato.
|
||||
|
||||
### 3. Avvia la piattaforma
|
||||
|
||||
**Senza pagamenti** (admin, catalogo, email):
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Con pagamenti e webhook Stripe attivi** (consigliato):
|
||||
|
||||
```bash
|
||||
docker compose --profile dev up -d
|
||||
```
|
||||
|
||||
Il primo avvio richiede **5–10 minuti**: Docker scarica le immagini, installa le dipendenze npm, compila Next.js, esegue le migrazioni e crea l'utente admin.
|
||||
|
||||
Segui il progresso con:
|
||||
@@ -76,33 +91,57 @@ Al primo accesso il sistema ti obbliga a cambiare la password.
|
||||
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
|
||||
### 7. Setup webhook Stripe (una tantum per macchina)
|
||||
|
||||
| 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) |
|
||||
Il profilo `dev` include un container `stripe-cli` che riceve i webhook da Stripe e li inoltra all'app. Senza di esso, dopo il pagamento l'ordine resta in `PENDING` e nessuna email viene inviata.
|
||||
|
||||
**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)):
|
||||
**Al primo avvio con `--profile dev`, recupera il webhook secret dai log:**
|
||||
|
||||
```bash
|
||||
docker compose logs stripe-cli | grep "webhook signing secret"
|
||||
```
|
||||
|
||||
Copia il valore `whsec_...` nel `.env`:
|
||||
|
||||
```env
|
||||
STRIPE_SECRET_KEY=sk_test_la_tua_chiave
|
||||
STRIPE_WEBHOOK_SECRET=whsec_abc123...
|
||||
```
|
||||
|
||||
Poi riavvia l'app:
|
||||
Poi ricrea il container app per applicare la variabile (`restart` non ricarica il `.env`):
|
||||
|
||||
```bash
|
||||
docker compose restart app
|
||||
docker compose up -d --force-recreate app
|
||||
```
|
||||
|
||||
### 8. Ferma la piattaforma
|
||||
Da questo momento `docker compose --profile dev up -d` avvia tutto inclusi i webhook — non serve altro.
|
||||
|
||||
### 8. Testa i pagamenti
|
||||
|
||||
**Carte di test Stripe:**
|
||||
|
||||
| Carta | Risultato |
|
||||
|-------|-----------|
|
||||
| `4242 4242 4242 4242` | Pagamento riuscito |
|
||||
| `4000 0000 0000 0002` | Carta rifiutata |
|
||||
| `4000 0025 0000 3155` | Richiede autenticazione 3D Secure |
|
||||
|
||||
Scadenza: qualsiasi data futura · CVC: qualsiasi 3 cifre
|
||||
|
||||
**Verifica che tutto funzioni dopo un pagamento:**
|
||||
|
||||
```bash
|
||||
docker compose down # ferma i container, i dati restano
|
||||
docker compose down -v # ferma e cancella anche il database
|
||||
docker compose logs stripe-cli --tail=20
|
||||
# deve mostrare [200] per checkout.session.completed
|
||||
```
|
||||
|
||||
1. L'ordine nel pannello admin passa da `PENDING` a `PAID`
|
||||
2. L'email di conferma appare su http://localhost:8025
|
||||
|
||||
### 9. Ferma la piattaforma
|
||||
|
||||
```bash
|
||||
docker compose --profile dev down # ferma tutto (con stripe-cli), i dati restano
|
||||
docker compose down -v # ferma e cancella anche il database
|
||||
```
|
||||
|
||||
---
|
||||
@@ -133,13 +172,13 @@ Cinque variabili da aggiornare nel `.env`:
|
||||
|-----------|-----------|------------|
|
||||
| `APP_URL` | `http://localhost` | `https://tuodominio.com` |
|
||||
| `AUTH_SECRET` | qualsiasi stringa | `openssl rand -hex 32` |
|
||||
| `STRIPE_SECRET_KEY` | `sk_test_...` (gratuita) | `sk_live_...` |
|
||||
| `STRIPE_WEBHOOK_SECRET` | opzionale | obbligatorio |
|
||||
| `STRIPE_SECRET_KEY` | `sk_test_...` | `sk_live_...` |
|
||||
| `STRIPE_WEBHOOK_SECRET` | dal container stripe-cli | dal dashboard Stripe |
|
||||
| `SMTP_*` | Mailpit locale (porta 8025) | provider reale (Resend, Mailgun, SendGrid…) |
|
||||
|
||||
### 3 — Server
|
||||
|
||||
Serve un VPS Linux con Docker installato (DigitalOcean, Hetzner, OVH, ecc.) e il record DNS del dominio puntato all'IP del server. Il comando di avvio è identico: `docker compose up -d`.
|
||||
Serve un VPS Linux con Docker installato (DigitalOcean, Hetzner, OVH, ecc.) e il record DNS del dominio puntato all'IP del server. Il comando di avvio è identico: `docker compose up -d` (senza `--profile dev`).
|
||||
|
||||
### 4 — Backup
|
||||
|
||||
@@ -221,8 +260,6 @@ tuodominio.com {
|
||||
}
|
||||
```
|
||||
|
||||
Caddy ottiene e rinnova automaticamente il certificato HTTPS tramite Let's Encrypt. Non serve nessuna configurazione SSL manuale.
|
||||
|
||||
### 5. Avvia
|
||||
|
||||
```bash
|
||||
@@ -251,10 +288,10 @@ Eventi da ascoltare:
|
||||
- payment_intent.payment_failed
|
||||
```
|
||||
|
||||
Copia il **Signing secret** (`whsec_...`) nel `.env` come `STRIPE_WEBHOOK_SECRET`, poi:
|
||||
Copia il **Signing secret** (`whsec_...`) nel `.env` come `STRIPE_WEBHOOK_SECRET`, poi ricrea il container per applicarlo:
|
||||
|
||||
```bash
|
||||
docker compose restart app
|
||||
docker compose up -d --force-recreate app
|
||||
```
|
||||
|
||||
### 7. Primo accesso e configurazione negozio
|
||||
@@ -290,8 +327,6 @@ docker compose restart app
|
||||
| **5432** | PostgreSQL (db) | Accessibile solo dall'app tramite `DATABASE_URL` |
|
||||
| **1025** | Mailpit SMTP | Accessibile solo dall'app per l'invio email |
|
||||
|
||||
Queste porte non sono pubblicate nel `docker-compose.yml` (`expose` ≠ `ports`) e non raggiungibili dall'esterno del server.
|
||||
|
||||
### Porta solo per sviluppo locale (non aprire in produzione)
|
||||
|
||||
| Porta | Servizio | Note |
|
||||
@@ -301,13 +336,9 @@ Queste porte non sono pubblicate nel `docker-compose.yml` (`expose` ≠ `ports`)
|
||||
### Riepilogo comandi firewall (UFW — Ubuntu/Debian)
|
||||
|
||||
```bash
|
||||
# Abilita solo HTTP e HTTPS
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
|
||||
# Blocca esplicitamente Mailpit dall'esterno (già bloccata di default se non aperta)
|
||||
sudo ufw deny 8025/tcp
|
||||
|
||||
sudo ufw enable
|
||||
sudo ufw status
|
||||
```
|
||||
@@ -362,6 +393,20 @@ 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`.
|
||||
|
||||
**L'ordine resta in PENDING dopo il pagamento**
|
||||
Il container `stripe-cli` non è in esecuzione o `STRIPE_WEBHOOK_SECRET` non è configurato. Verifica:
|
||||
```bash
|
||||
docker compose --profile dev ps # stripe-cli deve essere "running"
|
||||
docker compose logs stripe-cli --tail=10
|
||||
```
|
||||
Se il `whsec_...` manca nel `.env`, segui lo step 7 del setup locale.
|
||||
|
||||
**Le variabili del `.env` non vengono applicate dopo la modifica**
|
||||
`docker compose restart` non ricarica il `.env`. Usa sempre:
|
||||
```bash
|
||||
docker compose up -d --force-recreate app
|
||||
```
|
||||
|
||||
**Dimentico la password admin**
|
||||
Resetta direttamente nel database:
|
||||
```bash
|
||||
@@ -381,7 +426,7 @@ docker compose exec db psql -U ecommerce ecommerce -c \
|
||||
|
||||
```
|
||||
ecommerce-platform/
|
||||
├── docker-compose.yml Orchestrazione: db, app, caddy, mailpit
|
||||
├── docker-compose.yml Orchestrazione: db, app, caddy, mailpit, stripe-cli (profilo dev)
|
||||
├── Caddyfile Reverse proxy — modifica qui il dominio
|
||||
├── .env Variabili d'ambiente (non committare)
|
||||
├── .env.example Template da copiare
|
||||
@@ -413,7 +458,7 @@ ecommerce-platform/
|
||||
| `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 |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Segreto webhook Stripe | dal container stripe-cli | dal dashboard Stripe |
|
||||
| `SMTP_HOST` | Server SMTP | `mailpit` | provider reale |
|
||||
| `SMTP_PORT` | Porta SMTP | `1025` | `587` |
|
||||
| `SMTP_USER` | Utente SMTP | vuoto | obbligatorio |
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Navbar } from '@/components/storefront/Navbar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { useUser } from '@/context/UserContext'
|
||||
|
||||
interface CartItem {
|
||||
productId: string
|
||||
@@ -18,6 +19,7 @@ interface CartItem {
|
||||
export default function CartPage() {
|
||||
const [cart, setCart] = useState<CartItem[]>([])
|
||||
const router = useRouter()
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||
@@ -51,10 +53,9 @@ export default function CartPage() {
|
||||
|
||||
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
|
||||
async function handleCheckout() {
|
||||
const user = localStorage.getItem('user')
|
||||
function handleCheckout() {
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/cart')
|
||||
router.push('/login?redirect=/checkout')
|
||||
return
|
||||
}
|
||||
router.push('/checkout')
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function ProductDetailPage() {
|
||||
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||
const existingIndex = cart.findIndex(
|
||||
(item: { productId: string; variantId?: string }) =>
|
||||
item.productId === product.id && item.variantId === selectedVariant
|
||||
item.productId === product.id && item.variantId === (selectedVariant || undefined)
|
||||
)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
@@ -249,7 +249,12 @@ export default function ProductDetailPage() {
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
addToCart()
|
||||
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||
const alreadyInCart = cart.some(
|
||||
(item: { productId: string; variantId?: string }) =>
|
||||
item.productId === product.id && item.variantId === (selectedVariant || undefined)
|
||||
)
|
||||
if (!alreadyInCart) addToCart()
|
||||
router.push('/cart')
|
||||
}}
|
||||
>
|
||||
|
||||
+11
-4
@@ -28,7 +28,7 @@ services:
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- uploads:/app/public/uploads
|
||||
- ./data/uploads:/app/public/uploads
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
@@ -46,9 +46,16 @@ services:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./data/caddy/data:/data
|
||||
- ./data/caddy/config:/config
|
||||
- uploads:/srv/uploads
|
||||
- ./data/uploads:/srv/uploads
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
volumes:
|
||||
uploads:
|
||||
stripe-cli:
|
||||
image: stripe/stripe-cli:latest
|
||||
command: listen --forward-to http://app:3000/api/webhooks/stripe --api-key ${STRIPE_SECRET_KEY}
|
||||
depends_on:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- dev
|
||||
|
||||
|
||||
@@ -119,6 +119,27 @@ const res = await GET(req, { params: { id: 'prod-123' } })
|
||||
|
||||
La soglia minima configurata in `vitest.config.ts` è **70% di linee e funzioni**. Il comando `npm run test:coverage` fallisce se non viene rispettata. La copertura attuale è circa **11% di linee e 33% di funzioni** — la soglia va abbassata temporaneamente o la suite va estesa.
|
||||
|
||||
### Output atteso durante l'esecuzione
|
||||
|
||||
Durante `npm test` o `npm run test:coverage` possono comparire righe che sembrano errori ma sono normali:
|
||||
|
||||
```
|
||||
stderr | ...stripe.test.ts > returns 400 when signature verification fails
|
||||
Webhook signature verification failed: stripe_signature_mismatch
|
||||
```
|
||||
La route `/api/webhooks/stripe` logga intenzionalmente il messaggio quando la firma è invalida. Il test verifica proprio questo scenario — il `stderr` è corretto.
|
||||
|
||||
```
|
||||
stdout | ...stripe.test.ts > returns 200 for unhandled event types
|
||||
Unhandled event type: customer.created
|
||||
```
|
||||
Anche questo è il log della route per eventi Stripe non gestiti. Comportamento atteso.
|
||||
|
||||
```
|
||||
The CJS build of Vite's Node API is deprecated.
|
||||
```
|
||||
Warning di Vitest 1.x, innocuo. Sparirà aggiornando a Vitest 2+.
|
||||
|
||||
### Cosa manca (per priorità)
|
||||
|
||||
#### Alta priorità — logica di business critica
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import ProductDetailPage from '@/app/products/[slug]/page'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ slug: 'test-product' }),
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/storefront/Navbar', () => ({
|
||||
Navbar: () => null,
|
||||
}))
|
||||
|
||||
const mockProduct = {
|
||||
product: {
|
||||
id: 'prod-1',
|
||||
title: 'Test Product',
|
||||
slug: 'test-product',
|
||||
description: 'A test product',
|
||||
basePrice: 1000,
|
||||
currency: 'EUR',
|
||||
stock: 10,
|
||||
images: [],
|
||||
variants: [],
|
||||
categories: [],
|
||||
reviews: [],
|
||||
},
|
||||
}
|
||||
|
||||
describe('ProductDetailPage - cart behavior', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
mockPush.mockClear()
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ json: () => Promise.resolve(mockProduct) })
|
||||
)
|
||||
})
|
||||
|
||||
it('Add to Cart aggiunge 1 articolo al carrello', async () => {
|
||||
render(<ProductDetailPage />)
|
||||
await waitFor(() => screen.getByText('Test Product'))
|
||||
|
||||
fireEvent.click(screen.getByText('Add to Cart'))
|
||||
|
||||
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||
expect(cart).toHaveLength(1)
|
||||
expect(cart[0].quantity).toBe(1)
|
||||
expect(cart[0].productId).toBe('prod-1')
|
||||
})
|
||||
|
||||
it('Add to Cart cliccato due volte incrementa la quantità a 2', async () => {
|
||||
render(<ProductDetailPage />)
|
||||
await waitFor(() => screen.getByText('Test Product'))
|
||||
|
||||
fireEvent.click(screen.getByText('Add to Cart'))
|
||||
fireEvent.click(screen.getByText('Add to Cart'))
|
||||
|
||||
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||
expect(cart).toHaveLength(1)
|
||||
expect(cart[0].quantity).toBe(2)
|
||||
})
|
||||
|
||||
it('Buy Now aggiunge 1 articolo e naviga al carrello', async () => {
|
||||
render(<ProductDetailPage />)
|
||||
await waitFor(() => screen.getByText('Test Product'))
|
||||
|
||||
fireEvent.click(screen.getByText('Buy Now'))
|
||||
|
||||
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||
expect(cart).toHaveLength(1)
|
||||
expect(cart[0].quantity).toBe(1)
|
||||
expect(mockPush).toHaveBeenCalledWith('/cart')
|
||||
})
|
||||
|
||||
it('Add to Cart seguito da Buy Now non duplica: rimane 1 articolo con quantità 1', async () => {
|
||||
render(<ProductDetailPage />)
|
||||
await waitFor(() => screen.getByText('Test Product'))
|
||||
|
||||
fireEvent.click(screen.getByText('Add to Cart'))
|
||||
fireEvent.click(screen.getByText('Buy Now'))
|
||||
|
||||
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||
expect(cart).toHaveLength(1)
|
||||
expect(cart[0].quantity).toBe(1)
|
||||
expect(mockPush).toHaveBeenCalledWith('/cart')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user