Compare commits

..

23 Commits

Author SHA1 Message Date
Michele
9eb0786bcc Fix: Google button hover text visibility
- Add btn-outline CSS class with explicit hover state
- Button outline variant now properly shows cream text on hover
- Add text-cream utility class

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 17:35:25 +01:00
Michele
933440f327 docs: Add design system and basePath troubleshooting to CLAUDE.md
- Document Editorial Fresh design system (fonts, colors, classes)
- Add basePath gotchas for middleware and email redirect
- Update phase 1 status with design system note

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 17:13:32 +01:00
Michele
afdec23a84 Redesign: Editorial Fresh design system
Complete visual overhaul with distinctive editorial aesthetic:

Design System:
- New fonts: Fraunces (display) + DM Sans (body)
- Color palette: Cream background, ink text, coral accents
- Custom CSS variables and utility classes
- Sharp edges (no rounded corners) for editorial feel

Components:
- Updated Button with new variants and colors
- Updated Input with editorial styling
- New card-editorial class with accent bar

Pages:
- Homepage: Full redesign with hero, features, CTA
- Auth layout: Split screen with branding side
- Login/Register: Editorial styling with tags
- Dashboard: Cards with accent bars
- Subscription: Clean feature comparison table

Typography:
- Fraunces for all headings (serif, characterful)
- DM Sans for body text (clean, readable)
- Editorial tags (uppercase, accent color)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 16:27:23 +01:00
Michele
09d1c39ec0 Fix metadata: title, description, lang
- Change title from 'Create Next App' to 'Leopost'
- Add proper Italian description
- Set html lang to 'it'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 13:04:29 +01:00
Michele
94ddfad909 Fix email confirmation redirect missing basePath
- Use NEXT_PUBLIC_APP_URL instead of window.location.origin
- Ensures email confirmation redirects to /leopost/auth/callback/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 12:23:16 +01:00
Michele
eb5b2cd42c Update UAT: all Phase 1 tests passing
- Test 6 (middleware redirect) now passes after fix
- 12/12 tests passing
- Phase 1 UAT complete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 12:03:51 +01:00
Michele
44fcd37366 Fix middleware redirect URLs missing basePath
- Use request.nextUrl.clone() instead of new URL() for redirects
- This preserves the /leopost basePath in redirect URLs
- Fixes 404 error when unauthenticated user visits /dashboard

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 12:02:01 +01:00
Michele
2c2238548a test(01): complete UAT - 11 passed, 1 issue
Issue found:
- Middleware redirect missing basePath (redirects to /login/ instead of /leopost/login/)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 12:00:36 +01:00
Michele
64a4a5fcbc Add project documentation with deployment lessons learned
- Document nginx buffer configuration for Supabase Auth OAuth
- Document Next.js basePath/trailingSlash configuration
- Document middleware matcher best practices
- Add useful commands and current phase status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:13:25 +01:00
Michele
af3b007a3e Make OAuth URLs production-ready using NEXT_PUBLIC_APP_URL
- Remove hardcoded /leopost path from Google OAuth redirect
- Use environment variable for flexible deployment URL
- Fallback to window.location.origin for local development

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:46:06 +01:00
Michele
c561299ebd Fix OAuth callback: remove from middleware, fix redirect URLs
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:39:52 +01:00
Michele
ccc509fac6 Fix Google OAuth redirect URL to include basePath
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:32:27 +01:00
Michele
1f6e0d8356 Fix middleware: only run on auth-related routes
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 15:25:30 +01:00
Michele
14ff7739e9 Fix middleware: skip homepage to prevent empty response
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 15:22:05 +01:00
Michele
6d1c08dce4 Temporarily disable middleware for debugging
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 15:19:52 +01:00
Michele
47e1682d44 Simplify homepage to static (debug SSR issue)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 15:16:37 +01:00
Michele
79f9d73af0 Fix redirect loop: add trailingSlash config
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 15:01:25 +01:00
Michele
73fa80da7c Add basePath for proper subpath deployment with nginx
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:51:49 +01:00
Michele
9d4c13a13b Revert basePath - conflicts with nginx proxy strip
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:49:23 +01:00
Michele
fd1409dca7 Fix: add basePath for /leopost/ subpath deployment
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:47:00 +01:00
Michele
53407df43e Update config: deployment completed with Supabase
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:33:53 +01:00
Michele
7e38ce3c1c Increase memory limit to 1GB for Next.js build
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:31:21 +01:00
Michele
a1148a0a47 Fix: include devDependencies for TypeScript build
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:28:31 +01:00
29 changed files with 1650 additions and 324 deletions

View File

@@ -0,0 +1,124 @@
# Requirements: Leopost
**Defined:** 2026-01-30
**Core Value:** L'AI fa il lavoro sporco del social media manager — minimo sforzo, massima resa
## v1 Requirements
Requirements per il rilascio iniziale. Ogni requirement mappa a fasi della roadmap.
### Authentication
- [ ] **AUTH-01**: Utente può registrarsi con email/password
- [ ] **AUTH-02**: Utente può accedere con Google OAuth
- [ ] **AUTH-03**: Sistema supporta 3 piani (Free, Creator, Pro) con limiti configurabili
- [ ] **AUTH-04**: Utente può collegare account Facebook tramite OAuth
### Onboarding
- [ ] **ONBR-01**: Wizard raccoglie info base (nome attività, settore, descrizione)
- [ ] **ONBR-02**: Contesto utente persiste tra sessioni (memoria)
### AI Chat
- [ ] **CHAT-01**: Interfaccia chat come esperienza principale dell'app
- [ ] **CHAT-02**: Utente può scegliere modello AI (GPT, Claude, Gemini)
- [ ] **CHAT-03**: AI propone azioni suggerite dopo completamento onboarding
- [ ] **CHAT-04**: AI memorizza e impara preferenze utente nel tempo
### Content Generation
- [ ] **CONT-01**: AI genera post testuali su richiesta dell'utente
- [ ] **CONT-02**: AI genera immagini per i post
- [ ] **CONT-03**: AI crea piano editoriale (calendario contenuti suggeriti)
### Scheduling & Publishing
- [ ] **SCHD-01**: Utente può programmare post con data/ora specifica
- [ ] **SCHD-02**: Post vengono pubblicati automaticamente su Facebook
- [ ] **SCHD-03**: Utente può configurare livello automazione (da approval a autopilot)
- [ ] **SCHD-04**: Calendario editoriale visuale mostra post programmati
### Messaging Integration
- [ ] **MSGN-01**: Utente può inviare comandi via Telegram bot
- [ ] **MSGN-02**: Utente può inviare comandi via WhatsApp bot
## v2 Requirements
Deferred per release future. Tracciati ma non nella roadmap corrente.
### Authentication
- **AUTH-05**: Utente può accedere con Facebook OAuth
- **AUTH-06**: Utente può accedere con LinkedIn OAuth
- **AUTH-07**: Utente può accedere con Instagram OAuth
### Social Platforms
- **SOCL-01**: Utente può collegare e pubblicare su Instagram
- **SOCL-02**: Utente può collegare e pubblicare su LinkedIn
- **SOCL-03**: Utente può collegare e pubblicare su YouTube
- **SOCL-04**: Utente può collegare e pubblicare su TikTok
- **SOCL-05**: Utente può collegare e pubblicare su X (Twitter)
### Onboarding Extended
- **ONBR-03**: Wizard raccoglie brand kit (logo, colori, font)
- **ONBR-04**: Wizard raccoglie siti web e link
- **ONBR-05**: Wizard raccoglie target audience e zona geografica
### Content Generation Extended
- **CONT-04**: AI adatta tono/lunghezza/hashtag per piattaforma specifica
- **CONT-05**: Utente può fornire template grafici come base
- **CONT-06**: AI genera video (oltre a immagini)
## Out of Scope
Esclusi esplicitamente. Documentati per prevenire scope creep.
| Feature | Reason |
|---------|--------|
| App native iOS/Android | Architettura headless pronta, ma web-first per v1 |
| Dashboard analytics avanzate | Focus su creazione/pubblicazione, non analytics |
| Social listening | Complessità elevata, non core per freelance |
| Influencer marketplace | Fuori target (freelance, non brand) |
| White-label | Enterprise feature, non micro-SaaS |
| Video editing integrato | Complessità, costi storage/bandwidth |
| Gestione ads/campagne | Fuori scope, focus su contenuti organici |
## Traceability
Quali fasi coprono quali requirements. Aggiornato durante creazione roadmap.
| Requirement | Phase | Status |
|-------------|-------|--------|
| AUTH-01 | TBD | Pending |
| AUTH-02 | TBD | Pending |
| AUTH-03 | TBD | Pending |
| AUTH-04 | TBD | Pending |
| ONBR-01 | TBD | Pending |
| ONBR-02 | TBD | Pending |
| CHAT-01 | TBD | Pending |
| CHAT-02 | TBD | Pending |
| CHAT-03 | TBD | Pending |
| CHAT-04 | TBD | Pending |
| CONT-01 | TBD | Pending |
| CONT-02 | TBD | Pending |
| CONT-03 | TBD | Pending |
| SCHD-01 | TBD | Pending |
| SCHD-02 | TBD | Pending |
| SCHD-03 | TBD | Pending |
| SCHD-04 | TBD | Pending |
| MSGN-01 | TBD | Pending |
| MSGN-02 | TBD | Pending |
**Coverage:**
- v1 requirements: 19 total
- Mapped to phases: 0
- Unmapped: 19 ⚠️
---
*Requirements defined: 2026-01-30*
*Last updated: 2026-01-30 after initial definition*

View File

@@ -0,0 +1,82 @@
---
status: complete
phase: 01-foundation-auth
source: 01-01-SUMMARY.md, 01-02-SUMMARY.md, 01-03-SUMMARY.md, 01-04-SUMMARY.md, 01-05-SUMMARY.md, 01-06-SUMMARY.md
started: 2026-01-31T20:15:00Z
updated: 2026-01-31T20:30:00Z
---
## Current Test
[testing complete]
## Tests
### 1. Homepage carica correttamente
expected: Visitando https://lab.mlhub.it/leopost/ si vede la landing page con titolo "Leopost", descrizione del prodotto, e pulsanti "Inizia gratis" e "Accedi"
result: pass
### 2. Pagina di registrazione
expected: Cliccando "Inizia gratis" o visitando /register/ si vede il form di registrazione con pulsante "Accedi con Google" in alto, divisore "oppure", e form email/password sotto
result: pass
### 3. Pagina di login
expected: Visitando /login/ si vede il form di login con pulsante "Accedi con Google" in alto, divisore "oppure", e form email/password sotto
result: pass
### 4. Registrazione con email/password
expected: Inserendo email e password validi nel form di registrazione e cliccando "Registrati", l'utente viene registrato e vede messaggio di conferma email
result: pass
### 5. Login con Google OAuth
expected: Cliccando "Accedi con Google", l'utente viene reindirizzato a Google, seleziona account, e torna autenticato alla dashboard
result: pass
### 6. Protezione route - utente non autenticato
expected: Visitando /dashboard/ senza essere loggati, si viene reindirizzati a /login/
result: pass
note: "Risolto - middleware aggiornato per usare request.nextUrl.clone() che preserva il basePath"
### 7. Dashboard dopo login
expected: Dopo il login, l'utente vede la dashboard con il suo piano attuale, checklist onboarding, e navigazione con nome utente
result: pass
### 8. Persistenza sessione
expected: Dopo il login, ricaricando la pagina (F5), l'utente rimane autenticato e vede ancora la dashboard
result: pass
### 9. Logout
expected: Cliccando sul menu utente e poi "Esci", l'utente viene disconnesso e reindirizzato alla pagina di login
result: pass
### 10. Pagina subscription
expected: Visitando /subscription/ da autenticati, si vedono i 3 piani (Gratuito, Creator, Pro) con prezzi, funzionalita, e pulsante per cambiare piano
result: pass
### 11. Cambio piano
expected: Cliccando "Passa a questo piano" su un piano diverso dal corrente, il piano dell'utente viene aggiornato immediatamente (visibile nel badge piano)
result: pass
### 12. Testi in italiano
expected: Tutti i testi dell'interfaccia sono in italiano (pulsanti, messaggi, descrizioni piani)
result: pass
## Summary
total: 12
passed: 12
issues: 0
pending: 0
skipped: 0
## Gaps
[all issues resolved]
### Resolved Issues
- truth: "Visitando /dashboard/ senza essere loggati, si viene reindirizzati a /login/"
status: resolved
reason: "Middleware usava new URL() che non preserva basePath. Corretto con request.nextUrl.clone()"
fix_commit: 44fcd37
test: 6

28
.vps-lab-config.json Normal file
View File

@@ -0,0 +1,28 @@
{
"type": "vps-lab",
"project_name": "Leopost",
"slug": "leopost",
"created_at": "2026-01-30T01:23:04Z",
"gitea": {
"repo_url": "https://git.mlhub.it/Michele/leopost",
"clone_url": "https://git.mlhub.it/Michele/leopost.git"
},
"vps": {
"deployed": true,
"url": "https://lab.mlhub.it/leopost/",
"last_deploy": "2026-01-31T13:33:00Z",
"container": "lab-leopost-app",
"path": "/opt/lab-leopost/"
},
"supabase": {
"enabled": true,
"project_ref": "cizyzbdylxxjjhgnvyub"
},
"resources": {
"limits": {
"ram_mb": 1024,
"cpu_percent": 100
},
"deployed_at": "2026-01-31T13:33:00Z"
}
}

262
CLAUDE.md Normal file
View File

@@ -0,0 +1,262 @@
# Leopost - Note di Progetto
## Panoramica
Social media manager potenziato dall'AI. Gestisce post su multiple piattaforme social.
**URL Live:** https://lab.mlhub.it/leopost/
**Repository:** https://git.mlhub.it/Michele/leopost
**Supabase Project:** `cizyzbdylxxjjhgnvyub`
## Stack Tecnico
- **Frontend:** Next.js 16 con App Router
- **Auth:** Supabase Auth (Email/Password + Google OAuth)
- **Database:** Supabase Cloud PostgreSQL
- **Deployment:** Docker su VPS con nginx reverse proxy
## Configurazione Critica
### Next.js per Subpath Deployment
```typescript
// next.config.ts
const nextConfig: NextConfig = {
basePath: '/leopost',
trailingSlash: true,
};
```
### Variabili Ambiente (.env su VPS)
```
SUPABASE_URL=https://cizyzbdylxxjjhgnvyub.supabase.co
SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...
APP_URL=https://lab.mlhub.it/leopost
```
---
## Problemi Riscontrati e Soluzioni
### 1. Build OOM (Out of Memory)
**Problema:** Build Next.js killed durante `npm run build` con 512MB RAM limit.
**Soluzione:** Aumentare memory limit in docker-compose.yml a 1024MB.
### 2. Redirect Loop HTTP/HTTPS
**Problema:** `/leopost` senza trailing slash causava loop redirect e downgrade a HTTP.
**Soluzione:**
- Aggiungere `trailingSlash: true` in next.config.ts
- Aggiungere location esplicita in nginx:
```nginx
location = /leopost {
return 301 https://$host/leopost/;
}
```
### 3. Middleware Intercetta Tutte le Route
**Problema:** Homepage bianca perché il middleware Next.js intercettava anche le pagine statiche.
**Soluzione:** Limitare il matcher del middleware solo alle route che richiedono auth check:
```typescript
export const config = {
matcher: [
'/dashboard/:path*',
'/settings/:path*',
'/subscription/:path*',
'/login',
'/login/',
'/register',
'/register/',
],
}
```
**NON includere:** `/`, `/auth/:path*`, pagine statiche pubbliche.
### 4. OAuth 502 Bad Gateway - Header Troppo Grandi (CRITICO)
**Problema:** Google OAuth callback restituiva 502 Bad Gateway.
**Causa:** Supabase Auth setta cookie JWT molto grandi (~4KB) nella risposta. I buffer nginx di default sono troppo piccoli.
**Soluzione - ENTRAMBI i livelli nginx richiedono configurazione:**
**A) lab-router** (`/opt/lab-router/projects/leopost.conf`):
```nginx
location /leopost/ {
proxy_pass http://lab-leopost-app:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Connection "";
# CRITICO per Supabase Auth
proxy_buffer_size 256k;
proxy_buffers 8 256k;
proxy_busy_buffers_size 512k;
}
```
**B) NPM (Nginx Proxy Manager)** - file `/data/nginx/custom/server_proxy.conf`:
```nginx
# Large buffer sizes for Supabase Auth JWT cookies
proxy_buffer_size 256k;
proxy_buffers 8 256k;
proxy_busy_buffers_size 512k;
```
**Importante:** Il 502 può venire da ENTRAMBI i nginx nella catena:
```
Browser → NPM → lab-router → app
```
Se solo lab-router ha i buffer grandi ma NPM no, il 502 viene da NPM.
### 5. OAuth Redirect URL Errati
**Problema:** Dopo Google auth, redirect a localhost o URL senza basePath.
**Soluzione:** Usare `NEXT_PUBLIC_APP_URL` per costruire redirect URL dinamici:
```typescript
// src/app/auth/callback/route.ts
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://lab.mlhub.it/leopost'
return NextResponse.redirect(`${baseUrl}${next}`)
```
### 6. Middleware Redirect Senza basePath
**Problema:** Middleware redirect a `/login/` invece di `/leopost/login/` → 404.
**Causa:** `new URL('/login/', request.url)` non include il basePath.
**Soluzione:** Usare `request.nextUrl.clone()`:
```typescript
// middleware.ts
// ❌ SBAGLIATO
const redirectUrl = new URL('/login/', request.url)
// ✅ CORRETTO
const redirectUrl = request.nextUrl.clone()
redirectUrl.pathname = '/login/'
return NextResponse.redirect(redirectUrl)
```
### 7. Email Conferma Redirect Errato
**Problema:** Link conferma email reindirizza a `lab.mlhub.it/auth/callback` senza basePath.
**Causa:** `window.location.origin` non include basePath.
**Soluzione:** Usare `NEXT_PUBLIC_APP_URL`:
```typescript
// src/components/auth/register-form.tsx
const appUrl = process.env.NEXT_PUBLIC_APP_URL || window.location.origin
await supabase.auth.signUp({
email, password,
options: {
emailRedirectTo: `${appUrl}/auth/callback/`,
}
})
```
---
## Configurazione Supabase Dashboard
### Authentication > URL Configuration
- **Site URL:** `https://lab.mlhub.it/leopost`
- **Redirect URLs:**
- `https://lab.mlhub.it/leopost/auth/callback`
- `https://lab.mlhub.it/leopost/auth/callback/`
### Authentication > Providers > Google
- Abilitare Google provider
- Configurare Client ID e Client Secret da Google Cloud Console
- Authorized redirect URI in Google: `https://cizyzbdylxxjjhgnvyub.supabase.co/auth/v1/callback`
---
## Comandi Utili
```bash
# Deploy aggiornamenti
cd "D:\Michele\Progetti\Claude\VPS echosystem\lab\leopost"
git add . && git commit -m "Update" && git push origin main
ssh mic@72.62.49.98 "cd /opt/lab-leopost && git pull && docker compose restart"
# Verificare log container
ssh mic@72.62.49.98 "docker logs --tail 50 lab-leopost-app"
# Verificare log nginx lab-router
ssh mic@72.62.49.98 "docker logs --tail 50 lab-router"
# Verificare log NPM (per errori 502)
ssh mic@72.62.49.98 "docker exec nginx-proxy-app-1 tail -30 /data/logs/proxy-host-8_error.log"
```
---
## Design System: "Editorial Fresh"
Il progetto usa un design system personalizzato chiamato "Editorial Fresh" - ispirato al design editoriale/magazine con tipografia forte e layout distintivo.
### Font
| Tipo | Font | Uso |
|------|------|-----|
| Display | **Fraunces** | Titoli, heading (serif con carattere) |
| Body | **DM Sans** | Testo, paragrafi, UI |
```css
.font-display { font-family: var(--font-display); }
.font-body { font-family: var(--font-body); }
```
### Palette Colori
| Nome | Valore | CSS Variable | Uso |
|------|--------|--------------|-----|
| Cream | `#FFFBF5` | `--color-cream` | Background principale |
| Cream Dark | `#F5F0E8` | `--color-cream-dark` | Background secondario |
| Ink | `#1A1A1A` | `--color-ink` | Testo principale, bottoni |
| Ink Light | `#4A4A4A` | `--color-ink-light` | Testo secondario |
| Ink Muted | `#7A7A7A` | `--color-ink-muted` | Testo disabilitato |
| Accent | `#E85A4F` | `--color-accent` | CTA, elementi distintivi (corallo) |
| Success | `#2D7A4F` | `--color-success` | Conferme |
| Error | `#C53030` | `--color-error` | Errori |
### Classi Utility Custom
```css
/* Testi */
.text-accent /* Corallo */
.text-muted /* Grigio chiaro */
.text-ink /* Nero */
.text-ink-light /* Grigio scuro */
/* Background */
.bg-cream /* Sfondo principale */
.bg-cream-dark /* Sfondo alternativo */
.bg-accent-light /* Sfondo accent leggero */
/* Elementi editoriali */
.editorial-tag /* Tag uppercase corallo (SOCIAL MEDIA MANAGER) */
.editorial-line /* Linea decorativa 60px × 3px corallo */
.card-editorial /* Card con barra accent in cima */
```
### Componenti
| Componente | File | Note |
|------------|------|------|
| Button | `src/components/ui/button.tsx` | Varianti: default, outline, ghost, accent |
| Input | `src/components/ui/input.tsx` | Bordi quadrati, focus nero |
| Card Editorial | CSS class | Bordo top accent, padding 2rem |
### Principi di Design
1. **Niente bordi arrotondati** - Stile editoriale con angoli vivi
2. **Tipografia forte** - Fraunces per impatto, DM Sans per leggibilità
3. **Spazio generoso** - Padding abbondante, respiro tra elementi
4. **Accent limitato** - Corallo usato con parsimonia per CTA e emphasis
5. **Animazioni sottili** - fade-up on load, transizioni 200ms
### Come Mantenere Coerenza
Quando crei nuove pagine/componenti:
1. Usa `font-display` per tutti i titoli
2. Usa le classi `text-*` e `bg-*` del design system
3. Bottoni principali con `variant="default"` (nero → accent on hover)
4. Form inputs senza border-radius
5. Ogni sezione inizia con `editorial-tag` + titolo `font-display`
---
## Stato Fasi
- [x] **Fase 1:** Autenticazione (Email/Password + Google OAuth) + Design System
- [ ] **Fase 2:** Dashboard e gestione account social
- [ ] **Fase 3:** Creazione e scheduling post
- [ ] **Fase 4:** Integrazione AI per generazione contenuti

38
README.md Normal file
View File

@@ -0,0 +1,38 @@
# Leopost
Lab project hosted at https://lab.mlhub.it/leopost/
## Development
This project uses [gsd (get-shit-done)](https://github.com/glittercowboy/get-shit-done) for spec-driven development.
### Getting Started
1. Run `/gsd:new-project` to initialize project specs
2. Follow gsd workflow: discuss → plan → execute → verify
3. Run `vps-lab-deploy` when ready to deploy
### Project Structure
```
leopost/
├── .planning/ # gsd planning docs (created by /gsd:new-project)
│ ├── PROJECT.md # Project vision
│ ├── REQUIREMENTS.md # Scoped requirements
│ ├── ROADMAP.md # Phase breakdown
│ └── STATE.md # Project memory
├── src/ # Source code (created during development)
└── README.md # This file
```
## Repository
- **Gitea**: https://git.mlhub.it/Michele/leopost
- **Clone**: `git clone https://git.mlhub.it/Michele/leopost.git`
## Deployment
When ready to deploy, use `vps-lab-deploy` skill.
- **URL**: https://lab.mlhub.it/leopost/
- **VPS**: /opt/lab-leopost/

View File

@@ -19,10 +19,10 @@ services:
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY} - NEXT_PUBLIC_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY} - SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
- NEXT_PUBLIC_APP_URL=${APP_URL} - NEXT_PUBLIC_APP_URL=${APP_URL}
command: sh -c "npm install && npm run build && npm start" command: sh -c "npm install --include=dev && npm run build && npm start"
deploy: deploy:
resources: resources:
limits: limits:
memory: 512M memory: 1024M
cpus: '0.5' cpus: '1.0'
restart: unless-stopped restart: unless-stopped

View File

@@ -8,28 +8,33 @@ const protectedRoutes = ['/dashboard', '/settings', '/subscription']
const authRoutes = ['/login', '/register'] const authRoutes = ['/login', '/register']
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
const { supabaseResponse, user } = await updateSession(request)
const { pathname } = request.nextUrl const { pathname } = request.nextUrl
const { supabaseResponse, user } = await updateSession(request)
// Check if trying to access protected route without auth // Check if trying to access protected route without auth
const isProtectedRoute = protectedRoutes.some(route => const isProtectedRoute = protectedRoutes.some(route =>
pathname.startsWith(route) pathname === route || pathname === `${route}/` || pathname.startsWith(`${route}/`)
) )
if (isProtectedRoute && !user) { if (isProtectedRoute && !user) {
const redirectUrl = new URL('/login', request.url) // Use nextUrl.clone() to preserve basePath in redirect
// Save the original URL to redirect back after login const redirectUrl = request.nextUrl.clone()
redirectUrl.pathname = '/login/'
redirectUrl.searchParams.set('redirectTo', pathname) redirectUrl.searchParams.set('redirectTo', pathname)
return NextResponse.redirect(redirectUrl) return NextResponse.redirect(redirectUrl)
} }
// Check if trying to access auth routes while already authenticated // Check if trying to access auth routes while already authenticated
const isAuthRoute = authRoutes.some(route => const isAuthRoute = authRoutes.some(route =>
pathname.startsWith(route) pathname === route || pathname === `${route}/` || pathname.startsWith(`${route}/`)
) )
if (isAuthRoute && user) { if (isAuthRoute && user) {
return NextResponse.redirect(new URL('/dashboard', request.url)) // Use nextUrl.clone() to preserve basePath in redirect
const url = request.nextUrl.clone()
url.pathname = '/dashboard/'
return NextResponse.redirect(url)
} }
return supabaseResponse return supabaseResponse
@@ -37,13 +42,14 @@ export async function middleware(request: NextRequest) {
export const config = { export const config = {
matcher: [ matcher: [
/* // Only run middleware on specific routes that need auth handling
* Match all request paths except for the ones starting with: // Note: /auth/callback is excluded - it handles its own auth flow
* - _next/static (static files) '/dashboard/:path*',
* - _next/image (image optimization files) '/settings/:path*',
* - favicon.ico (favicon file) '/subscription/:path*',
* - public folder files '/login',
*/ '/login/',
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', '/register',
'/register/',
], ],
} }

View File

@@ -1,7 +1,8 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ basePath: '/leopost',
trailingSlash: true,
}; };
export default nextConfig; export default nextConfig;

View File

@@ -1,13 +1,62 @@
import Link from 'next/link'
export default function AuthLayout({ export default function AuthLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen bg-cream flex">
<div className="max-w-md w-full space-y-8"> {/* Left side - Branding */}
<div className="hidden lg:flex lg:w-1/2 bg-ink p-12 flex-col justify-between relative overflow-hidden">
{/* Decorative elements */}
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2"></div>
<div className="absolute bottom-0 left-0 w-96 h-96 bg-accent/5 rounded-full translate-y-1/2 -translate-x-1/2"></div>
{/* Logo */}
<Link href="/" className="relative z-10">
<span className="font-display text-3xl font-semibold text-cream">Leopost</span>
</Link>
{/* Quote */}
<div className="relative z-10 max-w-md">
<div className="w-12 h-1 bg-accent mb-8"></div>
<blockquote className="font-display text-3xl text-cream/90 leading-snug font-medium italic">
"Finalmente un assistente che capisce il mio brand e mi fa risparmiare ore ogni settimana."
</blockquote>
<div className="mt-8 flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-cream/20 flex items-center justify-center">
<span className="font-display text-cream font-semibold">MR</span>
</div>
<div>
<p className="text-cream font-medium">Marco Rossi</p>
<p className="text-cream/60 text-sm">Social Media Manager</p>
</div>
</div>
</div>
{/* Footer */}
<p className="text-cream/40 text-sm relative z-10">
© 2026 Leopost
</p>
</div>
{/* Right side - Form */}
<div className="flex-1 flex flex-col">
{/* Mobile header */}
<div className="lg:hidden p-6 border-b border-editorial">
<Link href="/" className="font-display text-2xl font-semibold">
Leopost
</Link>
</div>
{/* Form container */}
<div className="flex-1 flex items-center justify-center p-6 sm:p-12">
<div className="w-full max-w-md opacity-0 animate-fade-up">
{children} {children}
</div> </div>
</div> </div>
</div>
</div>
) )
} }

View File

@@ -1,30 +1,48 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { LoginForm } from '@/components/auth/login-form' import { LoginForm } from '@/components/auth/login-form'
import { GoogleSignInButton } from '@/components/auth/google-button' import { GoogleSignInButton } from '@/components/auth/google-button'
import Link from 'next/link'
export default function LoginPage() { export default function LoginPage() {
return ( return (
<Card> <div>
<CardHeader className="text-center"> {/* Header */}
<CardTitle>Accedi a Leopost</CardTitle> <div className="mb-8">
<CardDescription> <span className="editorial-tag">Bentornato</span>
<h1 className="mt-4 text-3xl font-display font-semibold">
Accedi a Leopost
</h1>
<p className="mt-2 text-ink-light">
Inserisci le tue credenziali per continuare Inserisci le tue credenziali per continuare
</CardDescription> </p>
</CardHeader> </div>
<CardContent className="space-y-4">
{/* Google Sign In */}
<GoogleSignInButton /> <GoogleSignInButton />
<div className="relative"> {/* Divider */}
<div className="relative my-8">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-300" /> <div className="w-full border-t border-editorial"></div>
</div> </div>
<div className="relative flex justify-center text-xs uppercase"> <div className="relative flex justify-center">
<span className="bg-white px-2 text-gray-500">oppure</span> <span className="bg-cream px-4 text-sm text-muted uppercase tracking-wide">
oppure
</span>
</div> </div>
</div> </div>
{/* Email/Password Form */}
<LoginForm /> <LoginForm />
</CardContent>
</Card> {/* Footer links */}
<div className="mt-8 pt-6 border-t border-editorial text-center">
<p className="text-sm text-ink-light">
Non hai un account?{' '}
<Link href="/register/" className="text-accent hover:underline font-medium">
Registrati gratis
</Link>
</p>
</div>
</div>
) )
} }

View File

@@ -1,30 +1,48 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { RegisterForm } from '@/components/auth/register-form' import { RegisterForm } from '@/components/auth/register-form'
import { GoogleSignInButton } from '@/components/auth/google-button' import { GoogleSignInButton } from '@/components/auth/google-button'
import Link from 'next/link'
export default function RegisterPage() { export default function RegisterPage() {
return ( return (
<Card> <div>
<CardHeader className="text-center"> {/* Header */}
<CardTitle>Crea il tuo account</CardTitle> <div className="mb-8">
<CardDescription> <span className="editorial-tag">Inizia gratis</span>
Inizia a usare Leopost gratuitamente <h1 className="mt-4 text-3xl font-display font-semibold">
</CardDescription> Crea il tuo account
</CardHeader> </h1>
<CardContent className="space-y-4"> <p className="mt-2 text-ink-light">
Inizia a gestire i tuoi social in modo intelligente
</p>
</div>
{/* Google Sign In */}
<GoogleSignInButton /> <GoogleSignInButton />
<div className="relative"> {/* Divider */}
<div className="relative my-8">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-300" /> <div className="w-full border-t border-editorial"></div>
</div> </div>
<div className="relative flex justify-center text-xs uppercase"> <div className="relative flex justify-center">
<span className="bg-white px-2 text-gray-500">oppure</span> <span className="bg-cream px-4 text-sm text-muted uppercase tracking-wide">
oppure
</span>
</div> </div>
</div> </div>
{/* Email/Password Form */}
<RegisterForm /> <RegisterForm />
</CardContent>
</Card> {/* Footer links */}
<div className="mt-8 pt-6 border-t border-editorial text-center">
<p className="text-sm text-ink-light">
Hai già un account?{' '}
<Link href="/login/" className="text-accent hover:underline font-medium">
Accedi
</Link>
</p>
</div>
</div>
) )
} }

View File

@@ -0,0 +1,97 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import Link from 'next/link'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export default function ResetPasswordPage() {
const [email, setEmail] = useState('')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const supabase = createClient()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
const { error: resetError } = await supabase.auth.resetPasswordForEmail(
email,
{
redirectTo: `${window.location.origin}/auth/callback?next=/update-password`,
}
)
if (resetError) {
setError(resetError.message)
setLoading(false)
return
}
setSuccess(true)
setLoading(false)
}
return (
<Card>
<CardHeader className="text-center">
<CardTitle>Recupera password</CardTitle>
<CardDescription>
Inserisci la tua email per ricevere il link di reset
</CardDescription>
</CardHeader>
<CardContent>
{success ? (
<div className="text-center space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
<p className="text-green-800">
Se l&apos;email esiste, riceverai un link per reimpostare la password.
</p>
</div>
<Link href="/login" className="text-blue-600 hover:underline">
Torna al login
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-800 text-sm">{error}</p>
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
required
placeholder="tu@esempio.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Invio...' : 'Invia link di reset'}
</Button>
<p className="text-center text-sm text-gray-600">
<Link href="/login" className="text-blue-600 hover:underline">
Torna al login
</Link>
</p>
</form>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,144 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import Link from 'next/link'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export default function UpdatePasswordPage() {
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const supabase = createClient()
function validatePassword(pwd: string): string | null {
if (pwd.length < 8) {
return 'La password deve contenere almeno 8 caratteri'
}
if (!/[0-9]/.test(pwd)) {
return 'La password deve contenere almeno un numero'
}
if (!/[A-Z]/.test(pwd)) {
return 'La password deve contenere almeno una lettera maiuscola'
}
return null
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setFieldErrors({})
setLoading(true)
// Validate password
const passwordError = validatePassword(password)
if (passwordError) {
setFieldErrors({ password: passwordError })
setLoading(false)
return
}
// Validate confirm password
if (password !== confirmPassword) {
setFieldErrors({ confirmPassword: 'Le password non coincidono' })
setLoading(false)
return
}
const { error: updateError } = await supabase.auth.updateUser({
password: password,
})
if (updateError) {
setError(updateError.message)
setLoading(false)
return
}
setSuccess(true)
setLoading(false)
}
return (
<Card>
<CardHeader className="text-center">
<CardTitle>Nuova password</CardTitle>
<CardDescription>
Inserisci la tua nuova password
</CardDescription>
</CardHeader>
<CardContent>
{success ? (
<div className="text-center space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
<p className="text-green-800">Password aggiornata con successo!</p>
</div>
<Link href="/login" className="text-blue-600 hover:underline">
Vai al login
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-800 text-sm">{error}</p>
</div>
)}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Nuova Password
</label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
placeholder="Minimo 8 caratteri"
value={password}
onChange={(e) => setPassword(e.target.value)}
error={!!fieldErrors.password}
/>
{fieldErrors.password && (
<p className="mt-1 text-sm text-red-600">{fieldErrors.password}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Almeno 8 caratteri, 1 numero, 1 maiuscola
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
Conferma Password
</label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
placeholder="Ripeti la password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
error={!!fieldErrors.confirmPassword}
/>
{fieldErrors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{fieldErrors.confirmPassword}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Aggiornamento...' : 'Aggiorna password'}
</Button>
</form>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,26 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import Link from 'next/link'
export default function VerifyEmailPage() {
return (
<Card>
<CardHeader className="text-center">
<CardTitle>Verifica la tua email</CardTitle>
<CardDescription>
Ti abbiamo inviato un link di conferma
</CardDescription>
</CardHeader>
<CardContent className="text-center space-y-4">
<p className="text-gray-600">
Controlla la tua casella email e clicca sul link per attivare il tuo account.
</p>
<p className="text-sm text-gray-500">
Non hai ricevuto l&apos;email? Controlla lo spam.
</p>
<Link href="/login" className="text-blue-600 hover:underline">
Torna al login
</Link>
</CardContent>
</Card>
)
}

View File

@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import Link from 'next/link'
export default async function DashboardPage() { export default async function DashboardPage() {
const supabase = await createClient() const supabase = await createClient()
@@ -32,81 +32,103 @@ export default async function DashboardPage() {
} | null } | null
return ( return (
<div className="space-y-6"> <div className="space-y-10 opacity-0 animate-fade-up">
{/* Header */}
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1> <span className="editorial-tag">Dashboard</span>
<p className="text-gray-500">Benvenuto in Leopost</p> <h1 className="mt-4 text-4xl font-display font-semibold">
Bentornato in Leopost
</h1>
<p className="mt-2 text-ink-light text-lg">
Gestisci i tuoi contenuti social da qui.
</p>
</div> </div>
{/* Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card> {/* Plan Card */}
<CardHeader> <div className="card-editorial">
<CardTitle className="text-lg">Il tuo piano</CardTitle> <div className="flex items-start justify-between mb-4">
<CardDescription> <h2 className="font-display text-xl font-semibold">Il tuo piano</h2>
<span className="editorial-tag">
{profile?.plans?.display_name_it || 'Gratuito'} {profile?.plans?.display_name_it || 'Gratuito'}
</CardDescription> </span>
</CardHeader> </div>
<CardContent> <ul className="space-y-3 text-ink-light">
<ul className="space-y-2 text-sm text-gray-600"> <li className="flex justify-between">
<li> <span>Post al mese</span>
<span className="font-medium">{features?.posts_per_month || 10}</span> post/mese <span className="font-medium text-ink">{features?.posts_per_month || 10}</span>
</li> </li>
<li> <li className="flex justify-between">
<span className="font-medium">{features?.social_accounts || 1}</span> account social <span>Account social</span>
<span className="font-medium text-ink">{features?.social_accounts || 1}</span>
</li> </li>
<li> <li className="flex justify-between">
<span className="font-medium">{features?.ai_models?.length || 1}</span> modelli AI <span>Modelli AI</span>
<span className="font-medium text-ink">{features?.ai_models?.length || 1}</span>
</li> </li>
</ul> </ul>
</CardContent> <div className="mt-6 pt-4 border-t border-editorial">
</Card> <Link
href="/subscription/"
className="text-sm text-accent hover:underline font-medium"
>
Cambia piano
</Link>
</div>
</div>
<Card> {/* Onboarding Card */}
<CardHeader> <div className="card-editorial">
<CardTitle className="text-lg">Prossimi passi</CardTitle> <h2 className="font-display text-xl font-semibold mb-4">Prossimi passi</h2>
<CardDescription> <ul className="space-y-4">
Completa la configurazione <li className="flex items-start gap-3">
</CardDescription> <span className="w-6 h-6 flex items-center justify-center bg-success text-white text-xs font-medium">
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<span className="w-5 h-5 rounded-full bg-green-100 text-green-600 flex items-center justify-center text-xs">
</span> </span>
Account creato <div>
<p className="font-medium text-ink">Account creato</p>
<p className="text-sm text-ink-light">Completo</p>
</div>
</li> </li>
<li className="flex items-center gap-2 text-gray-400"> <li className="flex items-start gap-3">
<span className="w-5 h-5 rounded-full bg-gray-100 flex items-center justify-center text-xs"> <span className="w-6 h-6 flex items-center justify-center bg-cream-dark text-ink-muted text-xs font-medium">
2 2
</span> </span>
Collega social (Phase 2) <div>
<p className="font-medium text-ink-muted">Collega social</p>
<p className="text-sm text-ink-muted">Prossimamente</p>
</div>
</li> </li>
<li className="flex items-center gap-2 text-gray-400"> <li className="flex items-start gap-3">
<span className="w-5 h-5 rounded-full bg-gray-100 flex items-center justify-center text-xs"> <span className="w-6 h-6 flex items-center justify-center bg-cream-dark text-ink-muted text-xs font-medium">
3 3
</span> </span>
Configura brand (Phase 3) <div>
<p className="font-medium text-ink-muted">Configura brand</p>
<p className="text-sm text-ink-muted">Prossimamente</p>
</div>
</li> </li>
</ul> </ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Attivita</CardTitle>
<CardDescription>
Le tue statistiche
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-4 text-gray-400 text-sm">
Nessuna attivita ancora.
<br />
Inizia collegando un account social!
</div> </div>
</CardContent>
</Card> {/* Activity Card */}
<div className="card-editorial">
<h2 className="font-display text-xl font-semibold mb-4">Attività</h2>
<div className="py-8 text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-cream-dark flex items-center justify-center">
<svg className="w-8 h-8 text-ink-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<p className="text-ink-muted text-sm">
Nessuna attività ancora.
</p>
<p className="text-ink-muted text-sm mt-1">
Collega un account social per iniziare!
</p>
</div>
</div>
</div> </div>
</div> </div>
) )

View File

@@ -31,25 +31,25 @@ export default async function DashboardLayout({
.single() .single()
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-cream">
{/* Header */} {/* Header */}
<header className="bg-white border-b border-gray-200"> <header className="bg-white border-b border-editorial sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6">
<div className="flex justify-between items-center h-16"> <div className="flex justify-between items-center h-16">
<div className="flex items-center gap-8"> <div className="flex items-center gap-10">
<Link href="/dashboard" className="text-xl font-bold text-blue-600"> <Link href="/dashboard/" className="font-display text-xl font-semibold text-ink">
Leopost Leopost
</Link> </Link>
<nav className="hidden md:flex items-center gap-4"> <nav className="hidden md:flex items-center gap-6">
<Link <Link
href="/dashboard" href="/dashboard/"
className="text-gray-600 hover:text-gray-900 text-sm font-medium" className="text-ink-light hover:text-ink transition-colors text-sm font-medium"
> >
Dashboard Dashboard
</Link> </Link>
<Link <Link
href="/subscription" href="/subscription/"
className="text-gray-600 hover:text-gray-900 text-sm font-medium" className="text-ink-light hover:text-ink transition-colors text-sm font-medium"
> >
Piano Piano
</Link> </Link>
@@ -64,7 +64,7 @@ export default async function DashboardLayout({
</header> </header>
{/* Main content */} {/* Main content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main className="max-w-7xl mx-auto px-6 py-10">
{children} {children}
</main> </main>
</div> </div>

View File

@@ -29,7 +29,7 @@ export default async function SubscriptionPage() {
if (plansError || !plans) { if (plansError || !plans) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-red-600">Errore nel caricamento dei piani</p> <p className="text-error">Errore nel caricamento dei piani</p>
</div> </div>
) )
} }
@@ -41,19 +41,23 @@ export default async function SubscriptionPage() {
}) })
return ( return (
<div className="space-y-8"> <div className="space-y-12 opacity-0 animate-fade-up">
{/* Header */}
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Il tuo abbonamento</h1> <span className="editorial-tag">Abbonamento</span>
<p className="text-gray-500 mt-1"> <h1 className="mt-4 text-4xl font-display font-semibold">
Scegli il piano piu adatto alle tue esigenze Scegli il tuo piano
</h1>
<p className="mt-2 text-ink-light text-lg">
Trova il piano più adatto alle tue esigenze
</p> </p>
</div> </div>
{/* Info banner */} {/* Info banner */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> <div className="p-5 bg-accent-light border-l-4 border-accent">
<p className="text-sm text-blue-800"> <p className="text-sm text-ink">
<strong>Nota:</strong> Il pagamento verra implementato nelle prossime versioni. <strong>Nota:</strong> Il pagamento verrà implementato nelle prossime versioni.
Per ora puoi passare liberamente tra i piani per testare le funzionalita. Per ora puoi passare liberamente tra i piani per testare le funzionalità.
</p> </p>
</div> </div>
@@ -69,19 +73,19 @@ export default async function SubscriptionPage() {
</div> </div>
{/* Feature comparison */} {/* Feature comparison */}
<div className="mt-12"> <div>
<h2 className="text-xl font-semibold text-gray-900 mb-4"> <h2 className="text-2xl font-display font-semibold mb-6">
Confronto funzionalita Confronto funzionalità
</h2> </h2>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full border-collapse"> <table className="w-full">
<thead> <thead>
<tr className="border-b"> <tr className="border-b-2 border-ink">
<th className="text-left py-3 px-4 font-medium text-gray-600"> <th className="text-left py-4 px-4 font-display font-semibold text-ink">
Funzionalita Funzionalità
</th> </th>
{sortedPlans.map((plan) => ( {sortedPlans.map((plan) => (
<th key={plan.id} className="text-center py-3 px-4 font-medium text-gray-900"> <th key={plan.id} className="text-center py-4 px-4 font-display font-semibold text-ink">
{plan.display_name_it} {plan.display_name_it}
</th> </th>
))} ))}
@@ -106,17 +110,17 @@ export default async function SubscriptionPage() {
<FeatureRow <FeatureRow
feature="Generazione immagini" feature="Generazione immagini"
plans={sortedPlans as Plan[]} plans={sortedPlans as Plan[]}
getValue={(p) => (p.features as PlanFeatures).image_generation ? '\u2713' : '\u2014'} getValue={(p) => (p.features as PlanFeatures).image_generation ? '' : ''}
/> />
<FeatureRow <FeatureRow
feature="Automazione" feature="Automazione"
plans={sortedPlans as Plan[]} plans={sortedPlans as Plan[]}
getValue={(p) => { getValue={(p) => {
const auto = (p.features as PlanFeatures).automation const auto = (p.features as PlanFeatures).automation
if (auto === false) return '\u2014' if (auto === false) return ''
if (auto === 'manual') return 'Manuale' if (auto === 'manual') return 'Manuale'
if (auto === 'full') return 'Completa' if (auto === 'full') return 'Completa'
return '\u2014' return ''
}} }}
/> />
</tbody> </tbody>
@@ -125,14 +129,14 @@ export default async function SubscriptionPage() {
</div> </div>
{/* FAQ */} {/* FAQ */}
<div className="mt-12"> <div>
<h2 className="text-xl font-semibold text-gray-900 mb-4"> <h2 className="text-2xl font-display font-semibold mb-6">
Domande frequenti Domande frequenti
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
<FaqItem <FaqItem
question="Posso cambiare piano in qualsiasi momento?" question="Posso cambiare piano in qualsiasi momento?"
answer="Si, puoi passare a un piano superiore o inferiore quando vuoi. Le modifiche sono immediate." answer="Sì, puoi passare a un piano superiore o inferiore quando vuoi. Le modifiche sono immediate."
/> />
<FaqItem <FaqItem
question="Cosa succede se supero i limiti del mio piano?" question="Cosa succede se supero i limiti del mio piano?"
@@ -140,7 +144,7 @@ export default async function SubscriptionPage() {
/> />
<FaqItem <FaqItem
question="Come funziona il pagamento?" question="Come funziona il pagamento?"
answer="Il sistema di pagamento verra implementato a breve. Per ora tutti i piani sono disponibili gratuitamente per i test." answer="Il sistema di pagamento verrà implementato a breve. Per ora tutti i piani sono disponibili gratuitamente per i test."
/> />
</div> </div>
</div> </div>
@@ -158,10 +162,10 @@ function FeatureRow({
getValue: (plan: Plan) => string getValue: (plan: Plan) => string
}) { }) {
return ( return (
<tr className="border-b"> <tr className="border-b border-editorial">
<td className="py-3 px-4 text-gray-600">{feature}</td> <td className="py-4 px-4 text-ink-light">{feature}</td>
{plans.map((plan) => ( {plans.map((plan) => (
<td key={plan.id} className="text-center py-3 px-4"> <td key={plan.id} className="text-center py-4 px-4 font-medium">
{getValue(plan)} {getValue(plan)}
</td> </td>
))} ))}
@@ -171,9 +175,9 @@ function FeatureRow({
function FaqItem({ question, answer }: { question: string; answer: string }) { function FaqItem({ question, answer }: { question: string; answer: string }) {
return ( return (
<div className="bg-gray-50 rounded-lg p-4"> <div className="card-editorial">
<h3 className="font-medium text-gray-900 mb-2">{question}</h3> <h3 className="font-display font-semibold text-ink mb-2">{question}</h3>
<p className="text-sm text-gray-600">{answer}</p> <p className="text-ink-light">{answer}</p>
</div> </div>
) )
} }

View File

@@ -2,19 +2,23 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url) const { searchParams } = new URL(request.url)
const code = searchParams.get('code') const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/dashboard' const next = searchParams.get('next') ?? '/dashboard/'
// Use the configured app URL for redirects
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://lab.mlhub.it/leopost'
if (code) { if (code) {
const supabase = await createClient() const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code) const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) { if (!error) {
return NextResponse.redirect(`${origin}${next}`) // Redirect to dashboard (or next page) after successful auth
return NextResponse.redirect(`${baseUrl}${next}`)
} }
} }
// Return the user to an error page with instructions // Return the user to login page with error
return NextResponse.redirect(`${origin}/login?error=auth_callback_error`) return NextResponse.redirect(`${baseUrl}/login/?error=auth_callback_error`)
} }

View File

@@ -1,26 +1,252 @@
@import "tailwindcss"; @import "tailwindcss";
:root { /* ==========================================================================
--background: #ffffff; LEOPOST DESIGN SYSTEM - "Editorial Fresh"
--foreground: #171717; ========================================================================== */
}
@theme inline { @theme inline {
--color-background: var(--background); /* Typography */
--color-foreground: var(--foreground); --font-body: var(--font-body), system-ui, sans-serif;
--font-sans: var(--font-geist-sans); --font-display: var(--font-display), Georgia, serif;
--font-mono: var(--font-geist-mono);
/* Color Palette */
--color-cream: #FFFBF5;
--color-cream-dark: #F5F0E8;
--color-ink: #1A1A1A;
--color-ink-light: #4A4A4A;
--color-ink-muted: #7A7A7A;
/* Accent - Coral Red */
--color-accent: #E85A4F;
--color-accent-hover: #D14940;
--color-accent-light: #FFF0EE;
/* Semantic */
--color-background: var(--color-cream);
--color-foreground: var(--color-ink);
--color-muted: var(--color-ink-muted);
--color-border: #E5E0D8;
--color-border-strong: #D0C9BD;
/* Success/Error */
--color-success: #2D7A4F;
--color-success-light: #E8F5ED;
--color-error: #C53030;
--color-error-light: #FEE2E2;
} }
@media (prefers-color-scheme: dark) { /* ==========================================================================
:root { BASE STYLES
--background: #0a0a0a; ========================================================================== */
--foreground: #ededed;
}
}
body { body {
background: var(--background); background-color: var(--color-cream);
color: var(--foreground); color: var(--color-ink);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Display Typography */
.font-display {
font-family: var(--font-display);
font-optical-sizing: auto;
}
.font-body {
font-family: var(--font-body);
}
/* Heading Styles */
h1, h2, h3, h4, h5, h6,
.heading {
font-family: var(--font-display);
font-weight: 600;
line-height: 1.2;
color: var(--color-ink);
letter-spacing: -0.02em;
}
/* ==========================================================================
EDITORIAL ELEMENTS
========================================================================== */
/* Decorative line */
.editorial-line {
width: 60px;
height: 3px;
background-color: var(--color-accent);
}
/* Pull quote style */
.pull-quote {
font-family: var(--font-display);
font-size: 1.5rem;
font-style: italic;
color: var(--color-ink-light);
border-left: 3px solid var(--color-accent);
padding-left: 1.5rem;
}
/* Label/Tag style */
.editorial-tag {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent);
}
/* ==========================================================================
COMPONENT OVERRIDES
========================================================================== */
/* Buttons - Primary */
.btn-primary {
background-color: var(--color-ink);
color: var(--color-cream);
font-weight: 500;
padding: 0.75rem 1.5rem;
border-radius: 0;
transition: all 0.2s ease;
}
.btn-primary:hover {
background-color: var(--color-accent);
transform: translateY(-1px);
}
/* Buttons - Secondary */
.btn-secondary {
background-color: transparent;
color: var(--color-ink);
font-weight: 500;
padding: 0.75rem 1.5rem;
border: 2px solid var(--color-ink);
border-radius: 0;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background-color: var(--color-ink);
color: var(--color-cream);
}
/* Button outline variant - explicit hover fix */
.btn-outline {
background-color: transparent;
color: var(--color-ink);
border: 2px solid var(--color-ink);
transition: all 0.2s ease;
}
.btn-outline:hover {
background-color: var(--color-ink);
color: var(--color-cream);
}
/* Cards */
.card-editorial {
background-color: white;
border: 1px solid var(--color-border);
padding: 2rem;
position: relative;
}
.card-editorial::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background-color: var(--color-accent);
}
/* Input fields */
input[type="text"],
input[type="email"],
input[type="password"],
textarea {
border: 1px solid var(--color-border);
border-radius: 0;
background-color: white;
padding: 0.75rem 1rem;
font-family: var(--font-body);
transition: border-color 0.2s ease;
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--color-ink);
}
/* ==========================================================================
UTILITIES
========================================================================== */
/* Text colors */
.text-accent { color: var(--color-accent); }
.text-muted { color: var(--color-ink-muted); }
.text-ink { color: var(--color-ink); }
.text-ink-light { color: var(--color-ink-light); }
.text-cream { color: var(--color-cream); }
/* Background colors */
.bg-cream { background-color: var(--color-cream); }
.bg-cream-dark { background-color: var(--color-cream-dark); }
.bg-accent-light { background-color: var(--color-accent-light); }
/* Border colors */
.border-editorial { border-color: var(--color-border); }
/* ==========================================================================
ANIMATIONS
========================================================================== */
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-up {
animation: fade-up 0.6s ease-out forwards;
}
.animate-fade-in {
animation: fade-in 0.4s ease-out forwards;
}
/* Staggered animations */
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.stagger-3 { animation-delay: 0.3s; }
.stagger-4 { animation-delay: 0.4s; }
/* ==========================================================================
SELECTION & FOCUS
========================================================================== */
::selection {
background-color: var(--color-accent);
color: white;
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
} }

View File

@@ -1,20 +1,25 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { DM_Sans, Fraunces } from "next/font/google";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const dmSans = DM_Sans({
variable: "--font-geist-sans", variable: "--font-body",
subsets: ["latin"], subsets: ["latin"],
display: "swap",
}); });
const geistMono = Geist_Mono({ const fraunces = Fraunces({
variable: "--font-geist-mono", variable: "--font-display",
subsets: ["latin"], subsets: ["latin"],
display: "swap",
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: {
description: "Generated by create next app", default: "Leopost",
template: "%s | Leopost",
},
description: "Social media manager potenziato dall'AI. Gestisci i tuoi post su multiple piattaforme social.",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -23,10 +28,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="it">
<body <body className={`${dmSans.variable} ${fraunces.variable} font-body antialiased`}>
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children} {children}
</body> </body>
</html> </html>

View File

@@ -1,47 +1,194 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { Button } from '@/components/ui/button'
export default async function Home() { export default function Home() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// If logged in, redirect to dashboard
if (user) {
redirect('/dashboard')
}
// Landing page for non-authenticated users
return ( return (
<main className="min-h-screen flex flex-col items-center justify-center p-8 bg-gradient-to-b from-blue-50 to-white"> <main className="min-h-screen bg-cream">
<div className="text-center max-w-2xl"> {/* Header */}
<h1 className="text-5xl font-bold text-gray-900 mb-4"> <header className="fixed top-0 left-0 right-0 z-50 bg-cream/90 backdrop-blur-sm border-b border-editorial">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<Link href="/" className="font-display text-2xl font-semibold tracking-tight">
Leopost Leopost
</h1> </Link>
<p className="text-xl text-gray-600 mb-8"> <nav className="flex items-center gap-6">
Il tuo social media manager potenziato dall&apos;AI. <Link
href="/login/"
className="text-ink-light hover:text-ink transition-colors text-sm font-medium"
>
Accedi
</Link>
<Link
href="/register/"
className="btn-primary text-sm"
>
Inizia gratis
</Link>
</nav>
</div>
</header>
{/* Hero Section */}
<section className="pt-32 pb-20 px-6">
<div className="max-w-6xl mx-auto">
<div className="grid lg:grid-cols-2 gap-16 items-center">
{/* Left Column - Text */}
<div className="opacity-0 animate-fade-up">
<span className="editorial-tag">Social Media Manager</span>
<h1 className="mt-6 text-5xl md:text-6xl lg:text-7xl font-display font-semibold leading-[1.1] tracking-tight">
Il tuo assistente
<br /> <br />
<span className="text-accent">editoriale</span>
<br />
potenziato dall&apos;AI
</h1>
<div className="editorial-line mt-8"></div>
<p className="mt-8 text-xl text-ink-light leading-relaxed max-w-lg">
Crea, programma e pubblica contenuti su tutti i tuoi canali social.
Minimo sforzo, massima resa. Minimo sforzo, massima resa.
</p> </p>
<div className="flex gap-4 justify-center"> <div className="mt-10 flex flex-col sm:flex-row gap-4">
<Link href="/register"> <Link href="/register/" className="btn-primary text-center">
<Button size="lg">
Inizia gratis Inizia gratis
</Button>
</Link> </Link>
<Link href="/login"> <Link href="/login/" className="btn-secondary text-center">
<Button variant="outline" size="lg"> Ho già un account
Accedi
</Button>
</Link> </Link>
</div> </div>
<p className="mt-8 text-sm text-gray-500"> <p className="mt-6 text-sm text-muted">
Nessuna carta richiesta. Piano gratuito disponibile. Nessuna carta richiesta · Piano gratuito per sempre
</p> </p>
</div> </div>
{/* Right Column - Visual */}
<div className="opacity-0 animate-fade-up stagger-2 hidden lg:block">
<div className="relative">
{/* Decorative background */}
<div className="absolute -inset-4 bg-cream-dark rounded-sm -rotate-2"></div>
{/* Main card */}
<div className="card-editorial relative">
<div className="space-y-6">
{/* Fake chat message */}
<div className="flex gap-4">
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
<span className="text-accent font-display font-semibold">L</span>
</div>
<div>
<p className="font-medium text-sm">Leopost AI</p>
<p className="mt-1 text-ink-light">
Ho preparato 3 post per la tua pagina Instagram.
Vuoi che li programmi per la prossima settimana?
</p>
</div>
</div>
{/* Divider */}
<div className="border-t border-editorial"></div>
{/* Post preview cards */}
<div className="grid grid-cols-3 gap-3">
<div className="aspect-square bg-cream-dark rounded-sm flex items-center justify-center">
<span className="text-2xl">📸</span>
</div>
<div className="aspect-square bg-cream-dark rounded-sm flex items-center justify-center">
<span className="text-2xl"></span>
</div>
<div className="aspect-square bg-cream-dark rounded-sm flex items-center justify-center">
<span className="text-2xl">🎯</span>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-3">
<button className="flex-1 py-2 text-sm font-medium bg-ink text-cream">
Programma tutti
</button>
<button className="flex-1 py-2 text-sm font-medium border border-editorial">
Modifica
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-20 px-6 bg-cream-dark">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16 opacity-0 animate-fade-up">
<span className="editorial-tag">Come funziona</span>
<h2 className="mt-4 text-4xl font-display font-semibold">
Tre passi verso la libertà editoriale
</h2>
</div>
<div className="grid md:grid-cols-3 gap-8">
{/* Feature 1 */}
<div className="opacity-0 animate-fade-up stagger-1">
<div className="text-5xl font-display font-semibold text-accent/30">01</div>
<h3 className="mt-4 text-xl font-display font-semibold">Connetti i tuoi canali</h3>
<p className="mt-2 text-ink-light">
Collega Facebook, Instagram e gli altri social in pochi click.
</p>
</div>
{/* Feature 2 */}
<div className="opacity-0 animate-fade-up stagger-2">
<div className="text-5xl font-display font-semibold text-accent/30">02</div>
<h3 className="mt-4 text-xl font-display font-semibold">Chatta con l&apos;AI</h3>
<p className="mt-2 text-ink-light">
Descrivi cosa vuoi comunicare. L&apos;AI crea contenuti su misura per te.
</p>
</div>
{/* Feature 3 */}
<div className="opacity-0 animate-fade-up stagger-3">
<div className="text-5xl font-display font-semibold text-accent/30">03</div>
<h3 className="mt-4 text-xl font-display font-semibold">Rilassati</h3>
<p className="mt-2 text-ink-light">
Leopost pubblica automaticamente. Tu pensa al tuo business.
</p>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-20 px-6">
<div className="max-w-3xl mx-auto text-center opacity-0 animate-fade-up">
<h2 className="text-4xl md:text-5xl font-display font-semibold">
Pronto a trasformare
<br />
la tua presenza social?
</h2>
<div className="editorial-line mx-auto mt-8"></div>
<p className="mt-8 text-xl text-ink-light">
Inizia oggi con il piano gratuito. Nessun vincolo.
</p>
<div className="mt-10">
<Link href="/register/" className="btn-primary inline-block">
Crea il tuo account
</Link>
</div>
</div>
</section>
{/* Footer */}
<footer className="py-8 px-6 border-t border-editorial">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
<div className="font-display text-lg font-semibold">Leopost</div>
<p className="text-sm text-muted">
© 2026 Leopost. Tutti i diritti riservati.
</p>
</div>
</footer>
</main> </main>
) )
} }

View File

@@ -32,13 +32,16 @@ export function GoogleSignInButton() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const supabase = createClient() const supabase = createClient()
// Use configured APP_URL for OAuth callback
const appUrl = process.env.NEXT_PUBLIC_APP_URL || window.location.origin
async function handleGoogleSignIn() { async function handleGoogleSignIn() {
setLoading(true) setLoading(true)
const { error } = await supabase.auth.signInWithOAuth({ const { error } = await supabase.auth.signInWithOAuth({
provider: 'google', provider: 'google',
options: { options: {
redirectTo: `${window.location.origin}/auth/callback`, redirectTo: `${appUrl}/auth/callback/`,
queryParams: { queryParams: {
access_type: 'offline', access_type: 'offline',
prompt: 'consent', prompt: 'consent',
@@ -59,10 +62,10 @@ export function GoogleSignInButton() {
variant="outline" variant="outline"
onClick={handleGoogleSignIn} onClick={handleGoogleSignIn}
disabled={loading} disabled={loading}
className="w-full flex items-center justify-center gap-2" className="w-full flex items-center justify-center gap-3 h-12 text-base"
> >
<GoogleIcon className="w-5 h-5" /> <GoogleIcon className="w-5 h-5" />
{loading ? 'Reindirizzamento...' : 'Accedi con Google'} {loading ? 'Reindirizzamento...' : 'Continua con Google'}
</Button> </Button>
) )
} }

View File

@@ -43,15 +43,15 @@ export function LoginForm() {
} }
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-5">
{error && ( {error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md"> <div className="p-4 bg-error-light border-l-4 border-error">
<p className="text-red-800 text-sm">{error}</p> <p className="text-error text-sm">{error}</p>
</div> </div>
)} )}
<div> <div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="email" className="block text-sm font-medium text-ink mb-2">
Email Email
</label> </label>
<Input <Input
@@ -67,7 +67,7 @@ export function LoginForm() {
</div> </div>
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="password" className="block text-sm font-medium text-ink mb-2">
Password Password
</label> </label>
<Input <Input
@@ -83,21 +83,14 @@ export function LoginForm() {
</div> </div>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<Link href="/reset-password" className="text-sm text-blue-600 hover:underline"> <Link href="/reset-password/" className="text-sm text-accent hover:underline">
Password dimenticata? Password dimenticata?
</Link> </Link>
</div> </div>
<Button type="submit" className="w-full" disabled={loading}> <Button type="submit" className="w-full h-12 text-base" disabled={loading}>
{loading ? 'Accesso...' : 'Accedi'} {loading ? 'Accesso in corso...' : 'Accedi'}
</Button> </Button>
<p className="text-center text-sm text-gray-600">
Non hai un account?{' '}
<Link href="/register" className="text-blue-600 hover:underline">
Registrati
</Link>
</p>
</form> </form>
) )
} }

View File

@@ -50,11 +50,14 @@ export function RegisterForm() {
return return
} }
// Use configured APP_URL to include basePath in redirect
const appUrl = process.env.NEXT_PUBLIC_APP_URL || window.location.origin
const { error: signUpError } = await supabase.auth.signUp({ const { error: signUpError } = await supabase.auth.signUp({
email, email,
password, password,
options: { options: {
emailRedirectTo: `${window.location.origin}/auth/callback`, emailRedirectTo: `${appUrl}/auth/callback/`,
} }
}) })
@@ -78,12 +81,15 @@ export function RegisterForm() {
if (success) { if (success) {
return ( return (
<div className="text-center"> <div className="text-center">
<div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-md"> <div className="mb-6 p-6 bg-success-light border-l-4 border-success">
<p className="text-green-800"> <p className="text-success font-medium">
Registrazione completata! Controlla la tua email per confermare l&apos;account. Registrazione completata!
</p>
<p className="mt-2 text-ink-light text-sm">
Controlla la tua email per confermare l&apos;account.
</p> </p>
</div> </div>
<Link href="/login" className="text-blue-600 hover:underline"> <Link href="/login/" className="text-accent hover:underline font-medium">
Torna al login Torna al login
</Link> </Link>
</div> </div>
@@ -91,15 +97,15 @@ export function RegisterForm() {
} }
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-5">
{error && ( {error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md"> <div className="p-4 bg-error-light border-l-4 border-error">
<p className="text-red-800 text-sm">{error}</p> <p className="text-error text-sm">{error}</p>
</div> </div>
)} )}
<div> <div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="email" className="block text-sm font-medium text-ink mb-2">
Email Email
</label> </label>
<Input <Input
@@ -115,7 +121,7 @@ export function RegisterForm() {
</div> </div>
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="password" className="block text-sm font-medium text-ink mb-2">
Password Password
</label> </label>
<Input <Input
@@ -130,15 +136,15 @@ export function RegisterForm() {
error={!!fieldErrors.password} error={!!fieldErrors.password}
/> />
{fieldErrors.password && ( {fieldErrors.password && (
<p className="mt-1 text-sm text-red-600">{fieldErrors.password}</p> <p className="mt-2 text-sm text-error">{fieldErrors.password}</p>
)} )}
<p className="mt-1 text-xs text-gray-500"> <p className="mt-2 text-xs text-muted">
Almeno 8 caratteri, 1 numero, 1 maiuscola Almeno 8 caratteri, 1 numero, 1 maiuscola
</p> </p>
</div> </div>
<div> <div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="confirmPassword" className="block text-sm font-medium text-ink mb-2">
Conferma Password Conferma Password
</label> </label>
<Input <Input
@@ -153,20 +159,13 @@ export function RegisterForm() {
error={!!fieldErrors.confirmPassword} error={!!fieldErrors.confirmPassword}
/> />
{fieldErrors.confirmPassword && ( {fieldErrors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{fieldErrors.confirmPassword}</p> <p className="mt-2 text-sm text-error">{fieldErrors.confirmPassword}</p>
)} )}
</div> </div>
<Button type="submit" className="w-full" disabled={loading}> <Button type="submit" className="w-full h-12 text-base" disabled={loading}>
{loading ? 'Registrazione...' : 'Registrati'} {loading ? 'Registrazione...' : 'Crea account'}
</Button> </Button>
<p className="text-center text-sm text-gray-600">
Hai gia un account?{' '}
<Link href="/login" className="text-blue-600 hover:underline">
Accedi
</Link>
</p>
</form> </form>
) )
} }

View File

@@ -22,19 +22,36 @@ export function UserNav({ email, planName }: UserNavProps) {
router.refresh() router.refresh()
} }
// Get initials from email
const initials = email
.split('@')[0]
.split(/[._-]/)
.slice(0, 2)
.map(part => part.charAt(0).toUpperCase())
.join('')
return ( return (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-sm text-right"> {/* Avatar */}
<p className="font-medium">{email}</p> <div className="w-9 h-9 bg-ink flex items-center justify-center">
<span className="text-cream text-sm font-medium">{initials}</span>
</div>
{/* Info */}
<div className="hidden sm:block text-sm text-right">
<p className="font-medium text-ink">{email}</p>
{planName && ( {planName && (
<p className="text-gray-500 text-xs capitalize">Piano {planName}</p> <p className="text-ink-muted text-xs">Piano {planName}</p>
)} )}
</div> </div>
{/* Logout button */}
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
onClick={handleSignOut} onClick={handleSignOut}
disabled={loading} disabled={loading}
className="text-ink-light hover:text-ink"
> >
{loading ? 'Uscita...' : 'Esci'} {loading ? 'Uscita...' : 'Esci'}
</Button> </Button>

View File

@@ -1,9 +1,8 @@
'use client' 'use client'
import { Plan, PlanFeatures } from '@/types/database' import { Plan, PlanFeatures } from '@/types/database'
import { formatFeatureValue, formatPrice, FEATURE_LABELS, getPlanBadgeColor } from '@/lib/plans' import { formatFeatureValue, formatPrice, FEATURE_LABELS } from '@/lib/plans'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { switchPlan } from '@/app/actions/subscription' import { switchPlan } from '@/app/actions/subscription'
import { useTransition } from 'react' import { useTransition } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
@@ -41,51 +40,57 @@ export function PlanCard({ plan, isCurrentPlan, onPlanChange }: PlanCardProps) {
'automation', 'automation',
] ]
const isPro = plan.name === 'pro'
return ( return (
<Card className={`relative ${isCurrentPlan ? 'ring-2 ring-blue-500' : ''}`}> <div className={`relative bg-white border ${isCurrentPlan ? 'border-accent' : 'border-editorial'} ${isPro ? 'ring-2 ring-ink' : ''}`}>
{/* Top accent bar */}
<div className={`h-1 ${isPro ? 'bg-ink' : isCurrentPlan ? 'bg-accent' : 'bg-cream-dark'}`}></div>
{/* Current plan badge */}
{isCurrentPlan && ( {isCurrentPlan && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2"> <div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="bg-blue-500 text-white text-xs font-medium px-3 py-1 rounded-full"> <span className="bg-accent text-white text-xs font-medium px-3 py-1">
Piano attuale Piano attuale
</span> </span>
</div> </div>
)} )}
<CardHeader className="text-center pt-8"> {/* Header */}
<div className="mb-2"> <div className="p-6 text-center border-b border-editorial">
<span className={`inline-block px-3 py-1 text-xs font-medium rounded-full border ${getPlanBadgeColor(plan.name)}`}> <span className="editorial-tag">
{plan.display_name_it} {plan.display_name_it}
</span> </span>
</div> <div className="mt-4 font-display text-4xl font-semibold text-ink">
<CardTitle className="text-3xl">
{formatPrice(plan.price_monthly)} {formatPrice(plan.price_monthly)}
</CardTitle> </div>
<CardDescription> <p className="mt-2 text-sm text-ink-light">
{plan.name === 'free' && 'Perfetto per iniziare'} {plan.name === 'free' && 'Perfetto per iniziare'}
{plan.name === 'creator' && 'Per creator seri'} {plan.name === 'creator' && 'Per creator seri'}
{plan.name === 'pro' && 'Per professionisti'} {plan.name === 'pro' && 'Per professionisti'}
</CardDescription> </p>
</CardHeader> </div>
<CardContent> {/* Features */}
<ul className="space-y-3"> <div className="p-6">
<ul className="space-y-4">
{displayFeatures.map((featureKey) => { {displayFeatures.map((featureKey) => {
const value = features[featureKey] const value = features[featureKey]
const isIncluded = value !== false const isIncluded = value !== false
return ( return (
<li key={featureKey} className="flex items-center gap-2"> <li key={featureKey} className="flex items-center gap-3">
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${ <span className={`w-5 h-5 flex items-center justify-center text-xs font-medium ${
isIncluded ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400' isIncluded ? 'bg-success text-white' : 'bg-cream-dark text-ink-muted'
}`}> }`}>
{isIncluded ? '\u2713' : '\u2014'} {isIncluded ? '' : ''}
</span> </span>
<span className="text-sm"> <span className="text-sm">
<span className="font-medium"> <span className="font-medium text-ink">
{formatFeatureValue(featureKey, value)} {formatFeatureValue(featureKey, value)}
</span> </span>
{' '} {' '}
<span className="text-gray-500"> <span className="text-ink-light">
{FEATURE_LABELS[featureKey].toLowerCase()} {FEATURE_LABELS[featureKey].toLowerCase()}
</span> </span>
</span> </span>
@@ -93,9 +98,10 @@ export function PlanCard({ plan, isCurrentPlan, onPlanChange }: PlanCardProps) {
) )
})} })}
</ul> </ul>
</CardContent> </div>
<CardFooter> {/* Footer */}
<div className="p-6 pt-0">
{isCurrentPlan ? ( {isCurrentPlan ? (
<Button variant="outline" className="w-full" disabled> <Button variant="outline" className="w-full" disabled>
Piano attuale Piano attuale
@@ -105,14 +111,14 @@ export function PlanCard({ plan, isCurrentPlan, onPlanChange }: PlanCardProps) {
className="w-full" className="w-full"
onClick={handleSwitchPlan} onClick={handleSwitchPlan}
disabled={isPending} disabled={isPending}
variant={plan.name === 'pro' ? 'default' : 'outline'} variant={isPro ? 'default' : 'outline'}
> >
{isPending ? 'Cambio in corso...' : ( {isPending ? 'Cambio in corso...' : (
plan.price_monthly === 0 ? 'Passa a Gratuito' : `Passa a ${plan.display_name_it}` plan.price_monthly === 0 ? 'Passa a Gratuito' : `Passa a ${plan.display_name_it}`
)} )}
</Button> </Button>
)} )}
</CardFooter> </div>
</Card> </div>
) )
} }

View File

@@ -2,7 +2,7 @@ import { forwardRef, ButtonHTMLAttributes } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'outline' | 'ghost' variant?: 'default' | 'outline' | 'ghost' | 'accent'
size?: 'default' | 'sm' | 'lg' size?: 'default' | 'sm' | 'lg'
} }
@@ -11,18 +11,25 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
return ( return (
<button <button
className={cn( className={cn(
'inline-flex items-center justify-center rounded-md font-medium transition-colors', 'inline-flex items-center justify-center font-medium transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50', 'disabled:pointer-events-none disabled:opacity-50',
// Variants
{ {
'bg-blue-600 text-white hover:bg-blue-700': variant === 'default', // Primary - Black button
'border border-gray-300 bg-white hover:bg-gray-50': variant === 'outline', 'btn-primary': variant === 'default',
'hover:bg-gray-100': variant === 'ghost', // Outline - Border button (uses CSS class for reliable hover)
'btn-outline': variant === 'outline',
// Ghost - No background
'text-ink hover:bg-cream-dark': variant === 'ghost',
// Accent - Coral button
'bg-accent text-white hover:bg-accent-hover': variant === 'accent',
}, },
// Sizes - No border radius for editorial style
{ {
'h-10 px-4 py-2': size === 'default', 'h-11 px-5 py-2': size === 'default',
'h-8 px-3 text-sm': size === 'sm', 'h-9 px-4 text-sm': size === 'sm',
'h-12 px-6 text-lg': size === 'lg', 'h-13 px-8 text-lg': size === 'lg',
}, },
className className
)} )}

View File

@@ -10,11 +10,12 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
return ( return (
<input <input
className={cn( className={cn(
'flex h-10 w-full rounded-md border bg-white px-3 py-2 text-sm', 'flex h-11 w-full border bg-white px-4 py-2 text-base',
'placeholder:text-gray-400', 'placeholder:text-ink-muted',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1', 'focus:outline-none focus:border-ink',
'disabled:cursor-not-allowed disabled:opacity-50', 'disabled:cursor-not-allowed disabled:opacity-50',
error ? 'border-red-500' : 'border-gray-300', 'transition-colors duration-200',
error ? 'border-error' : 'border-editorial',
className className
)} )}
ref={ref} ref={ref}

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long