Compare commits
23 Commits
8b84fae379
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb0786bcc | ||
|
|
933440f327 | ||
|
|
afdec23a84 | ||
|
|
09d1c39ec0 | ||
|
|
94ddfad909 | ||
|
|
eb5b2cd42c | ||
|
|
44fcd37366 | ||
|
|
2c2238548a | ||
|
|
64a4a5fcbc | ||
|
|
af3b007a3e | ||
|
|
c561299ebd | ||
|
|
ccc509fac6 | ||
|
|
1f6e0d8356 | ||
|
|
14ff7739e9 | ||
|
|
6d1c08dce4 | ||
|
|
47e1682d44 | ||
|
|
79f9d73af0 | ||
|
|
73fa80da7c | ||
|
|
9d4c13a13b | ||
|
|
fd1409dca7 | ||
|
|
53407df43e | ||
|
|
7e38ce3c1c | ||
|
|
a1148a0a47 |
124
.planning/REQUIREMENTS.md.bak
Normal file
124
.planning/REQUIREMENTS.md.bak
Normal 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*
|
||||
82
.planning/phases/01-foundation-auth/01-UAT.md
Normal file
82
.planning/phases/01-foundation-auth/01-UAT.md
Normal 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
28
.vps-lab-config.json
Normal 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
262
CLAUDE.md
Normal 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
38
README.md
Normal 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/
|
||||
@@ -19,10 +19,10 @@ services:
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
|
||||
- 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:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '0.5'
|
||||
memory: 1024M
|
||||
cpus: '1.0'
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -8,28 +8,33 @@ const protectedRoutes = ['/dashboard', '/settings', '/subscription']
|
||||
const authRoutes = ['/login', '/register']
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { supabaseResponse, user } = await updateSession(request)
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
const { supabaseResponse, user } = await updateSession(request)
|
||||
|
||||
// Check if trying to access protected route without auth
|
||||
const isProtectedRoute = protectedRoutes.some(route =>
|
||||
pathname.startsWith(route)
|
||||
pathname === route || pathname === `${route}/` || pathname.startsWith(`${route}/`)
|
||||
)
|
||||
|
||||
if (isProtectedRoute && !user) {
|
||||
const redirectUrl = new URL('/login', request.url)
|
||||
// Save the original URL to redirect back after login
|
||||
// Use nextUrl.clone() to preserve basePath in redirect
|
||||
const redirectUrl = request.nextUrl.clone()
|
||||
redirectUrl.pathname = '/login/'
|
||||
redirectUrl.searchParams.set('redirectTo', pathname)
|
||||
return NextResponse.redirect(redirectUrl)
|
||||
}
|
||||
|
||||
// Check if trying to access auth routes while already authenticated
|
||||
const isAuthRoute = authRoutes.some(route =>
|
||||
pathname.startsWith(route)
|
||||
pathname === route || pathname === `${route}/` || pathname.startsWith(`${route}/`)
|
||||
)
|
||||
|
||||
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
|
||||
@@ -37,13 +42,14 @@ export async function middleware(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public folder files
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||
// Only run middleware on specific routes that need auth handling
|
||||
// Note: /auth/callback is excluded - it handles its own auth flow
|
||||
'/dashboard/:path*',
|
||||
'/settings/:path*',
|
||||
'/subscription/:path*',
|
||||
'/login',
|
||||
'/login/',
|
||||
'/register',
|
||||
'/register/',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
basePath: '/leopost',
|
||||
trailingSlash: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,12 +1,61 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
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="max-w-md w-full space-y-8">
|
||||
{children}
|
||||
<div className="min-h-screen bg-cream flex">
|
||||
{/* 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}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,30 +1,48 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { LoginForm } from '@/components/auth/login-form'
|
||||
import { GoogleSignInButton } from '@/components/auth/google-button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Accedi a Leopost</CardTitle>
|
||||
<CardDescription>
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<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
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<GoogleSignInButton />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-gray-500">oppure</span>
|
||||
</div>
|
||||
{/* Google Sign In */}
|
||||
<GoogleSignInButton />
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-8">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-editorial"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-cream px-4 text-sm text-muted uppercase tracking-wide">
|
||||
oppure
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Email/Password Form */}
|
||||
<LoginForm />
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,48 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { RegisterForm } from '@/components/auth/register-form'
|
||||
import { GoogleSignInButton } from '@/components/auth/google-button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Crea il tuo account</CardTitle>
|
||||
<CardDescription>
|
||||
Inizia a usare Leopost gratuitamente
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<GoogleSignInButton />
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<span className="editorial-tag">Inizia gratis</span>
|
||||
<h1 className="mt-4 text-3xl font-display font-semibold">
|
||||
Crea il tuo account
|
||||
</h1>
|
||||
<p className="mt-2 text-ink-light">
|
||||
Inizia a gestire i tuoi social in modo intelligente
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-gray-500">oppure</span>
|
||||
</div>
|
||||
{/* Google Sign In */}
|
||||
<GoogleSignInButton />
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-8">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-editorial"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-cream px-4 text-sm text-muted uppercase tracking-wide">
|
||||
oppure
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RegisterForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Email/Password Form */}
|
||||
<RegisterForm />
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
97
src/app/(auth)/reset-password/page.tsx
Normal file
97
src/app/(auth)/reset-password/page.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
144
src/app/(auth)/update-password/page.tsx
Normal file
144
src/app/(auth)/update-password/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/app/(auth)/verify-email/page.tsx
Normal file
26
src/app/(auth)/verify-email/page.tsx
Normal 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'email? Controlla lo spam.
|
||||
</p>
|
||||
<Link href="/login" className="text-blue-600 hover:underline">
|
||||
Torna al login
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
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() {
|
||||
const supabase = await createClient()
|
||||
@@ -32,81 +32,103 @@ export default async function DashboardPage() {
|
||||
} | null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-10 opacity-0 animate-fade-up">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-500">Benvenuto in Leopost</p>
|
||||
<span className="editorial-tag">Dashboard</span>
|
||||
<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>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Il tuo piano</CardTitle>
|
||||
<CardDescription>
|
||||
{/* Plan Card */}
|
||||
<div className="card-editorial">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h2 className="font-display text-xl font-semibold">Il tuo piano</h2>
|
||||
<span className="editorial-tag">
|
||||
{profile?.plans?.display_name_it || 'Gratuito'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li>
|
||||
<span className="font-medium">{features?.posts_per_month || 10}</span> post/mese
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">{features?.social_accounts || 1}</span> account social
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">{features?.ai_models?.length || 1}</span> modelli AI
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-3 text-ink-light">
|
||||
<li className="flex justify-between">
|
||||
<span>Post al mese</span>
|
||||
<span className="font-medium text-ink">{features?.posts_per_month || 10}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span>Account social</span>
|
||||
<span className="font-medium text-ink">{features?.social_accounts || 1}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span>Modelli AI</span>
|
||||
<span className="font-medium text-ink">{features?.ai_models?.length || 1}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-6 pt-4 border-t border-editorial">
|
||||
<Link
|
||||
href="/subscription/"
|
||||
className="text-sm text-accent hover:underline font-medium"
|
||||
>
|
||||
Cambia piano →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Prossimi passi</CardTitle>
|
||||
<CardDescription>
|
||||
Completa la configurazione
|
||||
</CardDescription>
|
||||
</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>
|
||||
Account creato
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-gray-400">
|
||||
<span className="w-5 h-5 rounded-full bg-gray-100 flex items-center justify-center text-xs">
|
||||
2
|
||||
</span>
|
||||
Collega social (Phase 2)
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-gray-400">
|
||||
<span className="w-5 h-5 rounded-full bg-gray-100 flex items-center justify-center text-xs">
|
||||
3
|
||||
</span>
|
||||
Configura brand (Phase 3)
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Onboarding Card */}
|
||||
<div className="card-editorial">
|
||||
<h2 className="font-display text-xl font-semibold mb-4">Prossimi passi</h2>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-6 h-6 flex items-center justify-center bg-success text-white text-xs font-medium">
|
||||
✓
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-ink">Account creato</p>
|
||||
<p className="text-sm text-ink-light">Completo</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-6 h-6 flex items-center justify-center bg-cream-dark text-ink-muted text-xs font-medium">
|
||||
2
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-ink-muted">Collega social</p>
|
||||
<p className="text-sm text-ink-muted">Prossimamente</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-6 h-6 flex items-center justify-center bg-cream-dark text-ink-muted text-xs font-medium">
|
||||
3
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-ink-muted">Configura brand</p>
|
||||
<p className="text-sm text-ink-muted">Prossimamente</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<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!
|
||||
{/* 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -31,25 +31,25 @@ export default async function DashboardLayout({
|
||||
.single()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-cream">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="bg-white border-b border-editorial sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/dashboard" className="text-xl font-bold text-blue-600">
|
||||
<div className="flex items-center gap-10">
|
||||
<Link href="/dashboard/" className="font-display text-xl font-semibold text-ink">
|
||||
Leopost
|
||||
</Link>
|
||||
<nav className="hidden md:flex items-center gap-4">
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-gray-600 hover:text-gray-900 text-sm font-medium"
|
||||
href="/dashboard/"
|
||||
className="text-ink-light hover:text-ink transition-colors text-sm font-medium"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/subscription"
|
||||
className="text-gray-600 hover:text-gray-900 text-sm font-medium"
|
||||
href="/subscription/"
|
||||
className="text-ink-light hover:text-ink transition-colors text-sm font-medium"
|
||||
>
|
||||
Piano
|
||||
</Link>
|
||||
@@ -64,7 +64,7 @@ export default async function DashboardLayout({
|
||||
</header>
|
||||
|
||||
{/* 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}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default async function SubscriptionPage() {
|
||||
if (plansError || !plans) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -41,19 +41,23 @@ export default async function SubscriptionPage() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-12 opacity-0 animate-fade-up">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Il tuo abbonamento</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Scegli il piano piu adatto alle tue esigenze
|
||||
<span className="editorial-tag">Abbonamento</span>
|
||||
<h1 className="mt-4 text-4xl font-display font-semibold">
|
||||
Scegli il tuo piano
|
||||
</h1>
|
||||
<p className="mt-2 text-ink-light text-lg">
|
||||
Trova il piano più adatto alle tue esigenze
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Nota:</strong> Il pagamento verra implementato nelle prossime versioni.
|
||||
Per ora puoi passare liberamente tra i piani per testare le funzionalita.
|
||||
<div className="p-5 bg-accent-light border-l-4 border-accent">
|
||||
<p className="text-sm text-ink">
|
||||
<strong>Nota:</strong> Il pagamento verrà implementato nelle prossime versioni.
|
||||
Per ora puoi passare liberamente tra i piani per testare le funzionalità.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -69,19 +73,19 @@ export default async function SubscriptionPage() {
|
||||
</div>
|
||||
|
||||
{/* Feature comparison */}
|
||||
<div className="mt-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Confronto funzionalita
|
||||
<div>
|
||||
<h2 className="text-2xl font-display font-semibold mb-6">
|
||||
Confronto funzionalità
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">
|
||||
Funzionalita
|
||||
<tr className="border-b-2 border-ink">
|
||||
<th className="text-left py-4 px-4 font-display font-semibold text-ink">
|
||||
Funzionalità
|
||||
</th>
|
||||
{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}
|
||||
</th>
|
||||
))}
|
||||
@@ -106,17 +110,17 @@ export default async function SubscriptionPage() {
|
||||
<FeatureRow
|
||||
feature="Generazione immagini"
|
||||
plans={sortedPlans as Plan[]}
|
||||
getValue={(p) => (p.features as PlanFeatures).image_generation ? '\u2713' : '\u2014'}
|
||||
getValue={(p) => (p.features as PlanFeatures).image_generation ? '✓' : '—'}
|
||||
/>
|
||||
<FeatureRow
|
||||
feature="Automazione"
|
||||
plans={sortedPlans as Plan[]}
|
||||
getValue={(p) => {
|
||||
const auto = (p.features as PlanFeatures).automation
|
||||
if (auto === false) return '\u2014'
|
||||
if (auto === false) return '—'
|
||||
if (auto === 'manual') return 'Manuale'
|
||||
if (auto === 'full') return 'Completa'
|
||||
return '\u2014'
|
||||
return '—'
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
@@ -125,14 +129,14 @@ export default async function SubscriptionPage() {
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<div className="mt-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-display font-semibold mb-6">
|
||||
Domande frequenti
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<FaqItem
|
||||
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
|
||||
question="Cosa succede se supero i limiti del mio piano?"
|
||||
@@ -140,7 +144,7 @@ export default async function SubscriptionPage() {
|
||||
/>
|
||||
<FaqItem
|
||||
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>
|
||||
@@ -158,10 +162,10 @@ function FeatureRow({
|
||||
getValue: (plan: Plan) => string
|
||||
}) {
|
||||
return (
|
||||
<tr className="border-b">
|
||||
<td className="py-3 px-4 text-gray-600">{feature}</td>
|
||||
<tr className="border-b border-editorial">
|
||||
<td className="py-4 px-4 text-ink-light">{feature}</td>
|
||||
{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)}
|
||||
</td>
|
||||
))}
|
||||
@@ -171,9 +175,9 @@ function FeatureRow({
|
||||
|
||||
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-medium text-gray-900 mb-2">{question}</h3>
|
||||
<p className="text-sm text-gray-600">{answer}</p>
|
||||
<div className="card-editorial">
|
||||
<h3 className="font-display font-semibold text-ink mb-2">{question}</h3>
|
||||
<p className="text-ink-light">{answer}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,19 +2,23 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
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 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) {
|
||||
const supabase = await createClient()
|
||||
const { error } = await supabase.auth.exchangeCodeForSession(code)
|
||||
|
||||
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 NextResponse.redirect(`${origin}/login?error=auth_callback_error`)
|
||||
// Return the user to login page with error
|
||||
return NextResponse.redirect(`${baseUrl}/login/?error=auth_callback_error`)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,252 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
/* ==========================================================================
|
||||
LEOPOST DESIGN SYSTEM - "Editorial Fresh"
|
||||
========================================================================== */
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
/* Typography */
|
||||
--font-body: var(--font-body), system-ui, sans-serif;
|
||||
--font-display: var(--font-display), Georgia, serif;
|
||||
|
||||
/* 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 {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
/* ==========================================================================
|
||||
BASE STYLES
|
||||
========================================================================== */
|
||||
|
||||
body {
|
||||
background-color: var(--color-cream);
|
||||
color: var(--color-ink);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { DM_Sans, Fraunces } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
const dmSans = DM_Sans({
|
||||
variable: "--font-body",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const fraunces = Fraunces({
|
||||
variable: "--font-display",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: {
|
||||
default: "Leopost",
|
||||
template: "%s | Leopost",
|
||||
},
|
||||
description: "Social media manager potenziato dall'AI. Gestisci i tuoi post su multiple piattaforme social.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,10 +28,8 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<html lang="it">
|
||||
<body className={`${dmSans.variable} ${fraunces.variable} font-body antialiased`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
221
src/app/page.tsx
221
src/app/page.tsx
@@ -1,47 +1,194 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default async 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
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col items-center justify-center p-8 bg-gradient-to-b from-blue-50 to-white">
|
||||
<div className="text-center max-w-2xl">
|
||||
<h1 className="text-5xl font-bold text-gray-900 mb-4">
|
||||
Leopost
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Il tuo social media manager potenziato dall'AI.
|
||||
<br />
|
||||
Minimo sforzo, massima resa.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Link href="/register">
|
||||
<Button size="lg">
|
||||
Inizia gratis
|
||||
</Button>
|
||||
<main className="min-h-screen bg-cream">
|
||||
{/* Header */}
|
||||
<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
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<Button variant="outline" size="lg">
|
||||
<nav className="flex items-center gap-6">
|
||||
<Link
|
||||
href="/login/"
|
||||
className="text-ink-light hover:text-ink transition-colors text-sm font-medium"
|
||||
>
|
||||
Accedi
|
||||
</Button>
|
||||
</Link>
|
||||
</Link>
|
||||
<Link
|
||||
href="/register/"
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
Inizia gratis
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p className="mt-8 text-sm text-gray-500">
|
||||
Nessuna carta richiesta. Piano gratuito disponibile.
|
||||
</p>
|
||||
</div>
|
||||
{/* 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 />
|
||||
<span className="text-accent">editoriale</span>
|
||||
<br />
|
||||
potenziato dall'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.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col sm:flex-row gap-4">
|
||||
<Link href="/register/" className="btn-primary text-center">
|
||||
Inizia gratis
|
||||
</Link>
|
||||
<Link href="/login/" className="btn-secondary text-center">
|
||||
Ho già un account
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-muted">
|
||||
Nessuna carta richiesta · Piano gratuito per sempre
|
||||
</p>
|
||||
</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'AI</h3>
|
||||
<p className="mt-2 text-ink-light">
|
||||
Descrivi cosa vuoi comunicare. L'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,13 +32,16 @@ export function GoogleSignInButton() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const supabase = createClient()
|
||||
|
||||
// Use configured APP_URL for OAuth callback
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || window.location.origin
|
||||
|
||||
async function handleGoogleSignIn() {
|
||||
setLoading(true)
|
||||
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
redirectTo: `${appUrl}/auth/callback/`,
|
||||
queryParams: {
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
@@ -59,10 +62,10 @@ export function GoogleSignInButton() {
|
||||
variant="outline"
|
||||
onClick={handleGoogleSignIn}
|
||||
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" />
|
||||
{loading ? 'Reindirizzamento...' : 'Accedi con Google'}
|
||||
{loading ? 'Reindirizzamento...' : 'Continua con Google'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,15 +43,15 @@ export function LoginForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-800 text-sm">{error}</p>
|
||||
<div className="p-4 bg-error-light border-l-4 border-error">
|
||||
<p className="text-error text-sm">{error}</p>
|
||||
</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
|
||||
</label>
|
||||
<Input
|
||||
@@ -67,7 +67,7 @@ export function LoginForm() {
|
||||
</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
|
||||
</label>
|
||||
<Input
|
||||
@@ -83,21 +83,14 @@ export function LoginForm() {
|
||||
</div>
|
||||
|
||||
<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?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Accesso...' : 'Accedi'}
|
||||
<Button type="submit" className="w-full h-12 text-base" disabled={loading}>
|
||||
{loading ? 'Accesso in corso...' : 'Accedi'}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,11 +50,14 @@ export function RegisterForm() {
|
||||
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({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||
emailRedirectTo: `${appUrl}/auth/callback/`,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -78,12 +81,15 @@ export function RegisterForm() {
|
||||
if (success) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-md">
|
||||
<p className="text-green-800">
|
||||
Registrazione completata! Controlla la tua email per confermare l'account.
|
||||
<div className="mb-6 p-6 bg-success-light border-l-4 border-success">
|
||||
<p className="text-success font-medium">
|
||||
Registrazione completata!
|
||||
</p>
|
||||
<p className="mt-2 text-ink-light text-sm">
|
||||
Controlla la tua email per confermare l'account.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/login" className="text-blue-600 hover:underline">
|
||||
<Link href="/login/" className="text-accent hover:underline font-medium">
|
||||
Torna al login
|
||||
</Link>
|
||||
</div>
|
||||
@@ -91,15 +97,15 @@ export function RegisterForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-800 text-sm">{error}</p>
|
||||
<div className="p-4 bg-error-light border-l-4 border-error">
|
||||
<p className="text-error text-sm">{error}</p>
|
||||
</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
|
||||
</label>
|
||||
<Input
|
||||
@@ -115,7 +121,7 @@ export function RegisterForm() {
|
||||
</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
|
||||
</label>
|
||||
<Input
|
||||
@@ -130,15 +136,15 @@ export function RegisterForm() {
|
||||
error={!!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
|
||||
</p>
|
||||
</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
|
||||
</label>
|
||||
<Input
|
||||
@@ -153,20 +159,13 @@ export function RegisterForm() {
|
||||
error={!!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>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Registrazione...' : 'Registrati'}
|
||||
<Button type="submit" className="w-full h-12 text-base" disabled={loading}>
|
||||
{loading ? 'Registrazione...' : 'Crea account'}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,19 +22,36 @@ export function UserNav({ email, planName }: UserNavProps) {
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
// Get initials from email
|
||||
const initials = email
|
||||
.split('@')[0]
|
||||
.split(/[._-]/)
|
||||
.slice(0, 2)
|
||||
.map(part => part.charAt(0).toUpperCase())
|
||||
.join('')
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-right">
|
||||
<p className="font-medium">{email}</p>
|
||||
{/* Avatar */}
|
||||
<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 && (
|
||||
<p className="text-gray-500 text-xs capitalize">Piano {planName}</p>
|
||||
<p className="text-ink-muted text-xs">Piano {planName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logout button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSignOut}
|
||||
disabled={loading}
|
||||
className="text-ink-light hover:text-ink"
|
||||
>
|
||||
{loading ? 'Uscita...' : 'Esci'}
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client'
|
||||
|
||||
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 { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { switchPlan } from '@/app/actions/subscription'
|
||||
import { useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
@@ -41,51 +40,57 @@ export function PlanCard({ plan, isCurrentPlan, onPlanChange }: PlanCardProps) {
|
||||
'automation',
|
||||
]
|
||||
|
||||
const isPro = plan.name === 'pro'
|
||||
|
||||
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 && (
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="text-center pt-8">
|
||||
<div className="mb-2">
|
||||
<span className={`inline-block px-3 py-1 text-xs font-medium rounded-full border ${getPlanBadgeColor(plan.name)}`}>
|
||||
{plan.display_name_it}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-3xl">
|
||||
{/* Header */}
|
||||
<div className="p-6 text-center border-b border-editorial">
|
||||
<span className="editorial-tag">
|
||||
{plan.display_name_it}
|
||||
</span>
|
||||
<div className="mt-4 font-display text-4xl font-semibold text-ink">
|
||||
{formatPrice(plan.price_monthly)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-ink-light">
|
||||
{plan.name === 'free' && 'Perfetto per iniziare'}
|
||||
{plan.name === 'creator' && 'Per creator seri'}
|
||||
{plan.name === 'pro' && 'Per professionisti'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
<ul className="space-y-3">
|
||||
{/* Features */}
|
||||
<div className="p-6">
|
||||
<ul className="space-y-4">
|
||||
{displayFeatures.map((featureKey) => {
|
||||
const value = features[featureKey]
|
||||
const isIncluded = value !== false
|
||||
|
||||
return (
|
||||
<li key={featureKey} className="flex items-center gap-2">
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||
isIncluded ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
|
||||
<li key={featureKey} className="flex items-center gap-3">
|
||||
<span className={`w-5 h-5 flex items-center justify-center text-xs font-medium ${
|
||||
isIncluded ? 'bg-success text-white' : 'bg-cream-dark text-ink-muted'
|
||||
}`}>
|
||||
{isIncluded ? '\u2713' : '\u2014'}
|
||||
{isIncluded ? '✓' : '—'}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium">
|
||||
<span className="font-medium text-ink">
|
||||
{formatFeatureValue(featureKey, value)}
|
||||
</span>
|
||||
{' '}
|
||||
<span className="text-gray-500">
|
||||
<span className="text-ink-light">
|
||||
{FEATURE_LABELS[featureKey].toLowerCase()}
|
||||
</span>
|
||||
</span>
|
||||
@@ -93,9 +98,10 @@ export function PlanCard({ plan, isCurrentPlan, onPlanChange }: PlanCardProps) {
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
<CardFooter>
|
||||
{/* Footer */}
|
||||
<div className="p-6 pt-0">
|
||||
{isCurrentPlan ? (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Piano attuale
|
||||
@@ -105,14 +111,14 @@ export function PlanCard({ plan, isCurrentPlan, onPlanChange }: PlanCardProps) {
|
||||
className="w-full"
|
||||
onClick={handleSwitchPlan}
|
||||
disabled={isPending}
|
||||
variant={plan.name === 'pro' ? 'default' : 'outline'}
|
||||
variant={isPro ? 'default' : 'outline'}
|
||||
>
|
||||
{isPending ? 'Cambio in corso...' : (
|
||||
plan.price_monthly === 0 ? 'Passa a Gratuito' : `Passa a ${plan.display_name_it}`
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { forwardRef, ButtonHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'outline' | 'ghost'
|
||||
variant?: 'default' | 'outline' | 'ghost' | 'accent'
|
||||
size?: 'default' | 'sm' | 'lg'
|
||||
}
|
||||
|
||||
@@ -11,18 +11,25 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
'inline-flex items-center justify-center font-medium transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
// Variants
|
||||
{
|
||||
'bg-blue-600 text-white hover:bg-blue-700': variant === 'default',
|
||||
'border border-gray-300 bg-white hover:bg-gray-50': variant === 'outline',
|
||||
'hover:bg-gray-100': variant === 'ghost',
|
||||
// Primary - Black button
|
||||
'btn-primary': variant === 'default',
|
||||
// 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-8 px-3 text-sm': size === 'sm',
|
||||
'h-12 px-6 text-lg': size === 'lg',
|
||||
'h-11 px-5 py-2': size === 'default',
|
||||
'h-9 px-4 text-sm': size === 'sm',
|
||||
'h-13 px-8 text-lg': size === 'lg',
|
||||
},
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -10,11 +10,12 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border bg-white px-3 py-2 text-sm',
|
||||
'placeholder:text-gray-400',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1',
|
||||
'flex h-11 w-full border bg-white px-4 py-2 text-base',
|
||||
'placeholder:text-ink-muted',
|
||||
'focus:outline-none focus:border-ink',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error ? 'border-red-500' : 'border-gray-300',
|
||||
'transition-colors duration-200',
|
||||
error ? 'border-error' : 'border-editorial',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user