Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0314560885 | ||
|
|
0286961e92 | ||
|
|
6372ef9c8d | ||
|
|
fcff950006 | ||
|
|
99ca3b6495 | ||
|
|
457a0fe23d | ||
|
|
d8fc6ef04f | ||
|
|
e99d853987 | ||
|
|
efca4243b6 | ||
|
|
abd6fafc0c |
@@ -1,38 +0,0 @@
|
|||||||
# Obiettivi di squadra: sezione in Squadra + widget in home
|
|
||||||
|
|
||||||
## Stato attuale
|
|
||||||
|
|
||||||
Al momento c'è un solo "obiettivo di squadra" ed è hardcoded nella home (`src/routes/index.tsx`, riga 136): **"90% di presenze ad agosto"** con una barra finta all'82%. Non esiste uno schema dati né una sezione dedicata, e non è collegato a calendario, presenze o statistiche. Le voci "obiettivi" nelle descrizioni di Squadra e Profilo si riferiscono ai badge individuali, non a obiettivi di squadra.
|
|
||||||
|
|
||||||
## Cosa faccio
|
|
||||||
|
|
||||||
1. **Modello dati locale** in `src/lib/crapp-data.ts`
|
|
||||||
|
|
||||||
- Nuovo tipo `ObiettivoSquadra`: id, titolo, descrizione, target, valore attuale, unità, scadenza (opzionale), icona.
|
|
||||||
- Array `obiettiviSquadra` con gli obiettivi demo della stagione.
|
|
||||||
|
|
||||||
2. **Sezione "Obiettivi di squadra" dentro la scheda Squadra** (`src/routes/squadra.tsx`)
|
|
||||||
|
|
||||||
- Nuova sezione con la lista completa degli obiettivi: barra di progresso, percentuale, stato (in corso / completato).
|
|
||||||
- Ordinati con gli obiettivi in corso in cima e i completati in fondo.
|
|
||||||
- Nessuna nuova rotta e nessuna modifica al bottom nav.
|
|
||||||
|
|
||||||
3. **Widget home dinamico** (`src/routes/index.tsx`)
|
|
||||||
|
|
||||||
- Sostituisce l'obiettivo hardcoded: mostra sempre il primo obiettivo in corso preso dalla lista.
|
|
||||||
- Progresso calcolato dai dati reali dove possibile (es. presenze derivate dagli eventi).
|
|
||||||
|
|
||||||
4. **Obiettivi demo iniziali**
|
|
||||||
|
|
||||||
- 90% di presenze ad agosto (collegato agli eventi di agosto).
|
|
||||||
- 70% di risposte entro 24h nel prossimo mese.
|
|
||||||
- Prima vittoria del campionato (collegato allo storico match).
|
|
||||||
- 5 vittorie in campionato (collegato allo storico match).
|
|
||||||
- 10 vittorie in campionato (collegato allo storico match).
|
|
||||||
- 1 evento di squadra al mese (pizzata, ecc.).
|
|
||||||
|
|
||||||
## Cosa non cambia
|
|
||||||
|
|
||||||
- Resta un prototipo offline: i dati restano in `src/lib/crapp-data.ts`.
|
|
||||||
- I badge individuali restano come sono in `src/lib/badges.ts` e nella rosa di `src/routes/squadra.tsx`.
|
|
||||||
- Bottom nav e rotte invariate.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Rendere CrAPP operativa: migrazione dal prototipo al Cloud
|
|
||||||
|
|
||||||
## Stato attuale
|
|
||||||
|
|
||||||
L'app è un prototipo UI con alcune funzioni già collegate a Lovable Cloud:
|
|
||||||
|
|
||||||
- Già sul Cloud: voti MVP (`mvp_voti`), iscrizioni push (`push_subscriptions`), sessioni scout (`scout_sessioni`), turni palloni (`turni_palloni`).
|
|
||||||
- Ancora in locale come dati demo: rosa giocatori, calendario eventi, storico partite, classifica CSI, statistiche individuali, presenze/assenze, badge.
|
|
||||||
|
|
||||||
## Cosa serve per renderla operativa
|
|
||||||
|
|
||||||
1. **Rosa e profili giocatori sul database**
|
|
||||||
- Creare tabella `profiles` (o `giocatori`) con nome, numero maglia, ruolo, data di nascita, foto profilo.
|
|
||||||
- Collegare ogni riga all'utente autenticato corrispondente.
|
|
||||||
- Rimuovere la rosa statica da `src/lib/crapp-data.ts` e caricarla dal backend.
|
|
||||||
|
|
||||||
2. **Autenticazione reale**
|
|
||||||
- Sostituire la semplice selezione "Chi sei?" in `localStorage` con login email/password o OAuth (Google).
|
|
||||||
- Ogni giocatore accede con le proprie credenziali e vede solo i propri dati modificabili.
|
|
||||||
- Necessaria per garantire che uno scout o un voto MVP provenga davvero da quel giocatore.
|
|
||||||
|
|
||||||
3. **Calendario eventi persistente**
|
|
||||||
- Tabella `eventi` con tipo, titolo, data, ora, luogo, avversario, casa/fuori.
|
|
||||||
- Tabella `presenze` (evento_id, giocatore_id, stato, aggiornato_il).
|
|
||||||
- I compleanni possono restare derivati dalla data di nascita dei giocatori.
|
|
||||||
|
|
||||||
4. **Statistiche e badge dinamici**
|
|
||||||
- Tabella `statistiche` o `azioni_scout` (evento_id, giocatore_id, tipo azione, valore, creato_il).
|
|
||||||
- I badge vengono calcolati in tempo reale dalle statistiche accumulate, senza valori fissi in `crapp-data.ts`.
|
|
||||||
|
|
||||||
5. **Scout live collegato ai dati reali**
|
|
||||||
- Le azioni registrate in `scout.tsx` devono scrivere sulle tabelle eventi/statistiche.
|
|
||||||
- Mantenere il lock di modifica singolo e l'attivazione solo il giorno della partita.
|
|
||||||
|
|
||||||
6. **Classifica CSI**
|
|
||||||
- Tabella `classifica` aggiornata manualmente da un admin o importata dal sito CSI quando disponibile.
|
|
||||||
- Per ora nessuna API CSI ufficiale: si inserisce a mano o si copia/incolla.
|
|
||||||
|
|
||||||
7. **Notifiche push definitive**
|
|
||||||
- Verificare che i cron job inviino correttamente i promemoria palloni.
|
|
||||||
- Aggiungere notifiche per conferma eventi, promemoria presenze, MVP votabile.
|
|
||||||
|
|
||||||
8. **Ruoli e permessi**
|
|
||||||
- Definire chi può creare/modificare eventi (capitano/admin).
|
|
||||||
- Chi può fare scout (designato per partita).
|
|
||||||
- Chi può modificare i turni palloni (tutti, come da tua richiesta).
|
|
||||||
|
|
||||||
## Cosa resta salvato nel Cloud
|
|
||||||
|
|
||||||
Sì: tutto ciò che viene scritto sulle tabelle Lovable Cloud/Supabase resta salvato online e condiviso tra tutti i dispositivi.
|
|
||||||
|
|
||||||
I dati demo in `src/lib/crapp-data.ts` invece no: sono file statici, quindi ogni aggiornamento dell'app li sovrascrive e ogni telefono li vede identici.
|
|
||||||
|
|
||||||
## Decisioni da prendere insieme
|
|
||||||
|
|
||||||
- Vuoi abilitare login email/password per ogni giocatore, o preferisci mantenere la selezione "Chi sei?" senza password per semplicità?
|
|
||||||
- Chi gestirà inserimento eventi e aggiornamento classifica: solo alcuni o tutta la squadra?
|
|
||||||
- Vuoi procedere per fasi (prima rosa + calendario + presenze, poi statistiche) o tutto insieme?
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Ridimensionamento badge nella lista squadra
|
|
||||||
|
|
||||||
## Obiettivo
|
|
||||||
|
|
||||||
Rendere i badge accanto al nome del giocatore nella lista squadra più compatti e meno invasivi, mantenendo lo stile stilizzato (icone Lucide colorate per grado) e lasciando la scheda espansa con una dimensione leggibile.
|
|
||||||
|
|
||||||
## Modifiche previste
|
|
||||||
|
|
||||||
1. **Lista squadra (`src/routes/squadra.tsx`)**
|
|
||||||
- Ridurre le icone badge sbloccati mostrate accanto al nome da `h-4 w-4` (16px) a `h-3 w-3` (12px).
|
|
||||||
- Mantenere il massimo di 3 badge visibili e il contatore `+N` con testo ridotto a `text-[9px]` per armonizzare.
|
|
||||||
- Lasciare invariati avatar, nome, ruolo ed età.
|
|
||||||
|
|
||||||
2. **Scheda giocatore espansa (`src/routes/squadra.tsx`)**
|
|
||||||
- Portare le icone badge dalla dimensione attuale a `h-4 w-4` (16px), leggibili ma non troppo grandi.
|
|
||||||
- Mantenere card, colori per grado (bronzo/argento/oro) e testi descrittivi.
|
|
||||||
|
|
||||||
3. **Verifica**
|
|
||||||
- Controllare la preview su `/squadra` per confermare che i badge in lista siano discreti e la scheda espansa rimanga leggibile.
|
|
||||||
- Eseguire build per assicurarsi che non ci siano errori di tipo o stile.
|
|
||||||
|
|
||||||
## Cosa non cambia
|
|
||||||
|
|
||||||
- Colori dei gradi, soglie badge, logica di sblocco e votazione MVP.
|
|
||||||
- Layout generale della pagina e bottom navigation.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Rimuovere placeholder "Livello 7" dal Profilo
|
|
||||||
|
|
||||||
## Obiettivo
|
|
||||||
|
|
||||||
Eliminare il testo statico "Livello 7" dalla scheda profilo, dato che non è collegato a nessun calcolo reale e l'utente preferisce toglierlo per ora.
|
|
||||||
|
|
||||||
## Modifica
|
|
||||||
|
|
||||||
- `src/routes/profilo.tsx`: rimuovere il paragrafo `<p className="font-display text-2xl leading-none">Livello 7</p>` (riga 116) e, se necessario, riallineare il layout circostante per evitare spazi vuoti strani.
|
|
||||||
|
|
||||||
## Verifica
|
|
||||||
|
|
||||||
- Build senza errori.
|
|
||||||
- Preview della pagina Profilo: nessun riferimento a "Livello" visibile.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Selezione giocatore al primo avvio
|
|
||||||
|
|
||||||
Aggiungere un flusso di onboarding che chiede "Chi sei?" la prima volta che l'app viene aperta, memorizzando la scelta in `localStorage`. Il profilo e la home si aggiorneranno automaticamente in base al giocatore selezionato.
|
|
||||||
|
|
||||||
## Cosa cambia
|
|
||||||
|
|
||||||
1. **Nuovo store `src/lib/user-store.ts`**
|
|
||||||
- Persiste in `localStorage` l'`id` del giocatore scelto.
|
|
||||||
- Espone `useGiocatoreCorrente()` che restituisce il giocatore selezionato o `null`.
|
|
||||||
- Espone `impostaGiocatore(id)` e `resetGiocatore()`.
|
|
||||||
|
|
||||||
2. **Nuova route `/benvenuto`**
|
|
||||||
- Schermata full-screen con logo, titolo "Benvenuto in CrAPP" e lista scrollabile della rosa.
|
|
||||||
- Ogni riga mostra iniziali, nome, ruolo e numero maglia.
|
|
||||||
- Al tap su un giocatore, lo store viene aggiornato e l'utente viene portato a `/`.
|
|
||||||
- Non mostra la bottom navigation.
|
|
||||||
|
|
||||||
3. **Reindirizzamento condizionato in `__root.tsx`**
|
|
||||||
- Se non è ancora stato selezionato un giocatore, qualunque route apre `/benvenuto`.
|
|
||||||
- Dopo la scelta, l'app funziona normalmente.
|
|
||||||
|
|
||||||
4. **Sostituzione di `giocatoreCorrente` con `useGiocatoreCorrente()`**
|
|
||||||
- Aggiornare `src/routes/index.tsx` per salutare il giocatore selezionato e mostrarne le statistiche rapide.
|
|
||||||
- Aggiornare `src/routes/profilo.tsx` per renderlo il profilo personale del giocatore scelto.
|
|
||||||
|
|
||||||
5. **Cambio utente dalle impostazioni**
|
|
||||||
- In `src/routes/profilo.tsx`, aggiungere una voce "Cambia giocatore" che resetta la selezione e porta a `/benvenuto`.
|
|
||||||
|
|
||||||
## Note tecniche
|
|
||||||
|
|
||||||
- `localStorage` viene letto solo lato client, usando `useSyncExternalStore` per evitare mismatch di hydration.
|
|
||||||
- La rosa reale è già presente in `src/lib/crapp-data.ts` (`giocatori`).
|
|
||||||
- La costante esportata `giocatoreCorrente` verrà rimossa; i componenti consumeranno il nuovo hook.
|
|
||||||
- Nessun backend richiesto: resta un prototipo locale.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Turno palloni nel calendario + promemoria
|
|
||||||
|
|
||||||
Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra tutti, con rotazione automatica proposta dall'app e promemoria (in-app e push).
|
|
||||||
|
|
||||||
## Cosa vedrà la squadra
|
|
||||||
|
|
||||||
- **Riga "Palloni" su ogni evento** (allenamento e partita) nella card del calendario e in Home: avatar + nome dell'incaricato, oppure "Da assegnare".
|
|
||||||
- **Chiunque può cambiarlo**: tocco sulla riga, si apre la lista della rosa, si sceglie il nome. La modifica è immediata e visibile a tutti.
|
|
||||||
- **Proposta automatica a rotazione**: l'app suggerisce chi non ha ancora fatto il turno di recente (o è stato meno volte incaricato). Il suggerimento è solo una proposta: resta sempre modificabile.
|
|
||||||
- **Storico turni** nella scheda squadra/giocatore: quante volte ciascuno ha portato i palloni.
|
|
||||||
- **Due promemoria per l'incaricato**:
|
|
||||||
1. il giorno stesso dell'evento in cui li deve **prendere** a fine allenamento/partita;
|
|
||||||
2. il giorno dell'evento successivo, per ricordargli di **riportarli**.
|
|
||||||
- I promemoria arrivano come banner ben visibile in Home e, per chi attiva le notifiche, come notifica push sul telefono.
|
|
||||||
|
|
||||||
## Impostazione tecnica
|
|
||||||
|
|
||||||
**Backend (Lovable Cloud)**
|
|
||||||
|
|
||||||
- Attivazione di Lovable Cloud.
|
|
||||||
- Tabella `eventi` (spostando i dati demo attuali su database) o, in alternativa minima, tabella `turni_palloni` con `evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`. Scelgo la seconda per limitare il refactor: gli eventi restano in `crapp-data.ts` finché non si passa a calendario dinamico.
|
|
||||||
- Tabella `push_subscriptions` (giocatore_id, endpoint, chiavi) per le notifiche.
|
|
||||||
- Grant espliciti + RLS: lettura e scrittura aperte a tutti gli utenti dell'app (nessun login previsto oggi → policy per `anon` limitate a queste tabelle, nessun dato personale sensibile).
|
|
||||||
- Server functions in `src/lib/palloni.functions.ts`: `getTurni`, `setTurno`, `suggerisciTurno`.
|
|
||||||
|
|
||||||
**Frontend**
|
|
||||||
|
|
||||||
- Nuovo componente `TurnoPalloni` usato in `EventoCard` e in Home.
|
|
||||||
- Lettura via TanStack Query (`ensureQueryData` nel loader, `useSuspenseQuery` nel componente), invalidazione dopo la modifica.
|
|
||||||
- Banner promemoria in Home basato su data odierna + evento successivo, mostrato solo al giocatore selezionato in `user-store`.
|
|
||||||
|
|
||||||
**Notifiche push**
|
|
||||||
|
|
||||||
- Service worker dedicato al messaging (separato dalla PWA esistente), chiavi VAPID salvate come secret.
|
|
||||||
- Schermata in Profilo: "Attiva notifiche palloni" con richiesta di permesso.
|
|
||||||
- Invio schedulato tramite un endpoint `src/routes/api/public/promemoria-palloni.ts` protetto da secret, richiamato una volta al giorno da un job pianificato (pg_cron).
|
|
||||||
- Nota: su iPhone le notifiche push funzionano solo se l'app è installata dalla schermata Home.
|
|
||||||
|
|
||||||
## Ordine di lavoro
|
|
||||||
|
|
||||||
1. Attivare Lovable Cloud e creare tabelle + policy.
|
|
||||||
2. Server functions + UI del turno palloni (assegnazione manuale condivisa).
|
|
||||||
3. Rotazione automatica suggerita + storico turni.
|
|
||||||
4. Banner promemoria in-app.
|
|
||||||
5. Notifiche push + job giornaliero.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"template": "tanstack_start_ts_current",
|
|
||||||
"revision": "tanstack_start_ts_current-9e5645c506e5"
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "crapp",
|
"name": "crapp",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lovable.dev/cloud-auth-js": "^1.1.2",
|
|
||||||
"@supabase/supabase-js": "^2.111.0",
|
"@supabase/supabase-js": "^2.111.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-query": "^5.101.1",
|
"@tanstack/react-query": "^5.101.1",
|
||||||
@@ -27,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
"@lovable.dev/vite-tanstack-config": "2.8.5",
|
"@tanstack/devtools-vite": "^0.8.3",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
@@ -130,14 +129,6 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
"@lovable.dev/cloud-auth-js": ["@lovable.dev/cloud-auth-js@1.1.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", {}, "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg=="],
|
|
||||||
|
|
||||||
"@lovable.dev/vite-plugin-dev-server-bridge": ["@lovable.dev/vite-plugin-dev-server-bridge@1.2.1", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-JdADRwpEJA5t0ggXbd96dND23Wsp69Af9lVSV5m7MnMGxJZlPgqAqz7HUKcfJESfI5M5yFZOl9e0x76JMMDOAg=="],
|
|
||||||
|
|
||||||
"@lovable.dev/vite-plugin-hmr-gate": ["@lovable.dev/vite-plugin-hmr-gate@1.3.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.3.5.tgz", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-LxCj6JIbYRQ6peMm2aVn/bhHLkwZ5eZ4Cn6gmh7LfjaplqZHlA24nhCowgt6ZLfbckc4d8tSy0He7H8EiFEXag=="],
|
|
||||||
|
|
||||||
"@lovable.dev/vite-tanstack-config": ["@lovable.dev/vite-tanstack-config@2.8.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-2.8.5.tgz", { "dependencies": { "@lovable.dev/vite-plugin-dev-server-bridge": "^1.2.1", "@lovable.dev/vite-plugin-hmr-gate": "^1.3.4", "@tanstack/devtools-vite": "^0.8.1", "lightningcss": "^1.30.0" }, "peerDependencies": { "@tailwindcss/vite": ">=4.0.0", "@tanstack/react-start": ">=1.100.0", "@vitejs/plugin-react": ">=4.0.0", "nitro": ">=3.0.260603-beta", "vite": ">=5.0.0 <9.0.0", "vite-tsconfig-paths": ">=6.0.0" }, "optionalPeers": ["nitro"] }, "sha512-qPNxEXjvRsrbJHKgHxuLWpRW/gu2nIM+62qjTUCbCno5nRPMKyJQNsgjh48qjAkS6T0MRIkY/3BENNXeGsi5hw=="],
|
|
||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" } }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" } }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="],
|
||||||
|
|
||||||
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
||||||
@@ -424,7 +415,7 @@
|
|||||||
|
|
||||||
"canvas-confetti": ["canvas-confetti@1.9.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
|
"canvas-confetti": ["canvas-confetti@1.9.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
|
||||||
|
|
||||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
|
|
||||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||||
|
|
||||||
@@ -800,10 +791,6 @@
|
|||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"@tanstack/devtools-bundler-core/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
|
||||||
|
|
||||||
"@tanstack/devtools-vite/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
|
||||||
|
|
||||||
"@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
"@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||||
|
|
||||||
"@tanstack/router-plugin/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
"@tanstack/router-plugin/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||||
@@ -820,6 +807,8 @@
|
|||||||
|
|
||||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||||
|
|
||||||
|
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"h3/srvx": ["srvx@0.12.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g=="],
|
"h3/srvx": ["srvx@0.12.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g=="],
|
||||||
|
|
||||||
"h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
"h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
||||||
|
|||||||
+1
-1
@@ -4,4 +4,4 @@ saveTextLockfile = true
|
|||||||
minimumReleaseAge = 86400
|
minimumReleaseAge = 86400
|
||||||
# Each entry bypasses the 24h guard for one package — confirm with the user
|
# Each entry bypasses the 24h guard for one package — confirm with the user
|
||||||
# before adding any.
|
# before adding any.
|
||||||
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config", "@lovable.dev/mcp-js", "@lovable.dev/vite-plugin-dev-server-bridge", "@lovable.dev/vite-plugin-hmr-gate", "@lovable.dev/email-js", "@lovable.dev/webhooks-js"]
|
minimumReleaseAgeExcludes = []
|
||||||
|
|||||||
@@ -12,6 +12,26 @@ all'indietro.
|
|||||||
|
|
||||||
## [Non rilasciato]
|
## [Non rilasciato]
|
||||||
|
|
||||||
|
## [0.9.2] - 2026-09-10
|
||||||
|
|
||||||
|
### Aggiunto
|
||||||
|
|
||||||
|
- **Dashboard amministratore** — nuova tab "Notifiche" che mostra quanti giocatori hanno
|
||||||
|
le notifiche push attive e chi sono.
|
||||||
|
|
||||||
|
### Modificato
|
||||||
|
|
||||||
|
- **Dashboard amministratore** — le sezioni impilate diventano un menu di tab scorrevole a
|
||||||
|
pillole (come Squadra e Campionato); nell'elenco Profili resta aperta una sola scheda
|
||||||
|
alla volta.
|
||||||
|
- **Profilo** — testi dei campi amministrativi semplificati (label email, rimossa la nota
|
||||||
|
su chi vede quei dati).
|
||||||
|
|
||||||
|
### Rimosso
|
||||||
|
|
||||||
|
- Le dipendenze e il codice legati all'editor Lovable (login social e reporting errori
|
||||||
|
verso l'editor): l'app non ci gira più.
|
||||||
|
|
||||||
## [0.9.1] - 2026-09-10
|
## [0.9.1] - 2026-09-10
|
||||||
|
|
||||||
### Modificato
|
### Modificato
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
|
|||||||
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
|
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
|
||||||
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
|
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
|
||||||
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
|
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
|
||||||
| `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. |
|
|
||||||
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
|
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
|
||||||
|
|
||||||
## Regole da rispettare nelle prossime modifiche
|
## Regole da rispettare nelle prossime modifiche
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# Modulo — Calendario ed Eventi
|
# Modulo — Calendario ed Eventi
|
||||||
|
|
||||||
**Stato:** implementato
|
**Stato:** implementato
|
||||||
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/routes/calendario.tsx`
|
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
|
||||||
(vista mensile, tutti), `src/routes/eventi.tsx` (creazione/modifica, solo admin),
|
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
|
||||||
`src/components/crapp/EventoCard.tsx` (card condivisa)
|
`src/routes/eventi.tsx` (creazione/modifica, solo admin), `src/components/crapp/EventoCard.tsx`
|
||||||
**Test:** `test/unit/eventi.test.ts`
|
(card condivisa)
|
||||||
|
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,7 +27,14 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve
|
|||||||
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
|
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
|
||||||
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
|
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
|
||||||
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
|
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
|
||||||
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database).
|
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). Sopra alla lista
|
||||||
|
cronologica c'è una griglia mensile (stessa logica di `/calendario`, tramite le funzioni
|
||||||
|
condivise di `src/lib/calendario.ts`): ogni giorno è cliccabile, anche senza eventi, e apre
|
||||||
|
un drawer con gli eventi di quel giorno (modifica/elimina) e un bottone "Nuovo evento in
|
||||||
|
questo giorno" che apre il form con la data già precompilata. Creare, modificare ed
|
||||||
|
eliminare passano solo da lì: la lista cronologica sotto il calendario è un elenco senza
|
||||||
|
azioni dirette, cliccare una riga apre lo stesso drawer del giorno corrispondente (anche se
|
||||||
|
è in un mese diverso da quello mostrato sulla griglia) invece di duplicare matita/cestino.
|
||||||
|
|
||||||
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
|
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
|
||||||
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
|
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
|
||||||
|
|||||||
@@ -82,10 +82,24 @@ route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi
|
|||||||
variabile d'ambiente.
|
variabile d'ambiente.
|
||||||
|
|
||||||
| Route | Controllo | Chi la chiama |
|
| Route | Controllo | Chi la chiama |
|
||||||
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante riservato agli admin |
|
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
|
||||||
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
|
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
|
||||||
|
|
||||||
|
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
|
||||||
|
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
|
||||||
|
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
|
||||||
|
La tab elenca tutti i giocatori attivi della squadra, non solo chi ha le notifiche abilitate:
|
||||||
|
l'icona (campana piena/barrata) distingue chi ha almeno un dispositivo iscritto da chi non
|
||||||
|
l'ha ancora attivata.
|
||||||
|
|
||||||
|
`notifica-personalizzata` manda un messaggio libero scritto dall'admin: senza `giocatoreId`
|
||||||
|
lo manda a tutti i dispositivi iscritti in `push_subscriptions`, con `giocatoreId` solo a
|
||||||
|
quelli di quel giocatore. Titolo fisso ("Messaggio dallo staff"), corpo il testo scritto
|
||||||
|
dall'admin (max 300 caratteri). Stessa logica di pulizia delle altre route: una sottoscrizione
|
||||||
|
che risponde 404/410 viene cancellata dalla tabella. Nella tab "Notifiche" della dashboard
|
||||||
|
admin c'è un bottone "Invia messaggio a tutti" sopra l'elenco e un bottone per riga giocatore.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notifiche smart
|
## Notifiche smart
|
||||||
|
|||||||
@@ -165,9 +165,17 @@ Contiene.
|
|||||||
## Dashboard amministratore
|
## Dashboard amministratore
|
||||||
|
|
||||||
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
|
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
|
||||||
Profilo → Opzioni).
|
Profilo → Opzioni), organizzata in tab scorrevoli a pillole (`BarraSottosezioni`, stesso
|
||||||
|
componente di [Squadra](squadra.md) e Campionato): Squadra, Profili, Disattivati (solo se
|
||||||
|
c'è almeno un giocatore disattivato) e Notifiche.
|
||||||
|
|
||||||
Per ogni giocatore vengono mostrati.
|
La tab **Notifiche** mostra quanti giocatori attivi hanno almeno un dispositivo iscritto
|
||||||
|
alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (vedi
|
||||||
|
[Notifiche](notifiche.md)). È solo consultiva: l'attivazione resta un gesto che ogni
|
||||||
|
giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
|
||||||
|
di altri.
|
||||||
|
|
||||||
|
Per ogni giocatore, nella tab Profili, vengono mostrati.
|
||||||
|
|
||||||
- Stato del profilo
|
- Stato del profilo
|
||||||
- Certificato medico
|
- Certificato medico
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "crapp",
|
"name": "crapp",
|
||||||
"version": "0.9.1",
|
"version": "0.9.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
"test:all": "bun test/run.ts all"
|
"test:all": "bun test/run.ts all"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lovable.dev/cloud-auth-js": "^1.1.2",
|
|
||||||
"@supabase/supabase-js": "^2.111.0",
|
"@supabase/supabase-js": "^2.111.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-query": "^5.101.1",
|
"@tanstack/react-query": "^5.101.1",
|
||||||
@@ -39,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
"@lovable.dev/vite-tanstack-config": "2.8.5",
|
"@tanstack/devtools-vite": "^0.8.3",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
|
|||||||
@@ -304,10 +304,6 @@ export function ProfiloAmministrativo({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CampiProfilo
|
<CampiProfilo
|
||||||
corrente={corrente}
|
corrente={corrente}
|
||||||
aggiorna={aggiorna}
|
aggiorna={aggiorna}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
// This file is auto-generated by Lovable. Do not modify it.
|
|
||||||
|
|
||||||
import { createLovableAuth } from "@lovable.dev/cloud-auth-js";
|
|
||||||
import { supabase } from "../supabase/client";
|
|
||||||
const lovableAuth = createLovableAuth();
|
|
||||||
|
|
||||||
type SignInOptions = {
|
|
||||||
redirect_uri?: string;
|
|
||||||
extraParams?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const lovable = {
|
|
||||||
auth: {
|
|
||||||
signInWithOAuth: async (
|
|
||||||
provider: "google" | "apple" | "microsoft" | "lovable",
|
|
||||||
opts?: SignInOptions,
|
|
||||||
) => {
|
|
||||||
const result = await lovableAuth.signInWithOAuth(provider, {
|
|
||||||
redirect_uri: opts?.redirect_uri ?? window.location.origin,
|
|
||||||
extraParams: {
|
|
||||||
...opts?.extraParams,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.redirected) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await supabase.auth.setSession(result.tokens);
|
|
||||||
} catch (e) {
|
|
||||||
return { error: e instanceof Error ? e : new Error(String(e)) };
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
/** Etichette condivise tra `/calendario` (sola lettura) ed `/eventi` (griglia di gestione). */
|
||||||
|
export const giorniIT = ["L", "M", "M", "G", "V", "S", "D"];
|
||||||
|
|
||||||
|
export const mesiIT = [
|
||||||
|
"Gennaio",
|
||||||
|
"Febbraio",
|
||||||
|
"Marzo",
|
||||||
|
"Aprile",
|
||||||
|
"Maggio",
|
||||||
|
"Giugno",
|
||||||
|
"Luglio",
|
||||||
|
"Agosto",
|
||||||
|
"Settembre",
|
||||||
|
"Ottobre",
|
||||||
|
"Novembre",
|
||||||
|
"Dicembre",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function pad2(n: number) {
|
||||||
|
return String(n).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numero di giorni nel mese (0-indicizzato) e offset del primo giorno rispetto a lunedì. */
|
||||||
|
export function giorniDelMese(anno: number, mese: number) {
|
||||||
|
const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate();
|
||||||
|
const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay();
|
||||||
|
const offsetLunedi = (primoGiorno + 6) % 7;
|
||||||
|
return { giorni, offsetLunedi };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stato di navigazione mese per mese, condiviso dalle griglie calendario. */
|
||||||
|
export function useMeseNav(initial?: { anno: number; mese: number }) {
|
||||||
|
const oggi = new Date();
|
||||||
|
const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear());
|
||||||
|
const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth());
|
||||||
|
// Serve a far entrare e uscire la griglia dallo stesso lato del gesto:
|
||||||
|
// se un mese esce a sinistra, il precedente deve rientrare da sinistra.
|
||||||
|
const [direzione, setDirezione] = useState(0);
|
||||||
|
|
||||||
|
const precedente = () => {
|
||||||
|
setDirezione(-1);
|
||||||
|
if (mese === 0) {
|
||||||
|
setMese(11);
|
||||||
|
setAnno((a) => a - 1);
|
||||||
|
} else {
|
||||||
|
setMese((m) => m - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const successivo = () => {
|
||||||
|
setDirezione(1);
|
||||||
|
if (mese === 11) {
|
||||||
|
setMese(0);
|
||||||
|
setAnno((a) => a + 1);
|
||||||
|
} else {
|
||||||
|
setMese((m) => m + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { anno, mese, direzione, precedente, successivo };
|
||||||
|
}
|
||||||
+1
-1
@@ -104,7 +104,7 @@ export function eventoVuoto(): Evento {
|
|||||||
id: nuovoIdEvento(),
|
id: nuovoIdEvento(),
|
||||||
tipo: "allenamento",
|
tipo: "allenamento",
|
||||||
titolo: "",
|
titolo: "",
|
||||||
luogo: "Palestra Comunale",
|
luogo: "",
|
||||||
data: new Date().toISOString().slice(0, 10),
|
data: new Date().toISOString().slice(0, 10),
|
||||||
ora: "20:30",
|
ora: "20:30",
|
||||||
note: "",
|
note: "",
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
type LovableErrorOptions = {
|
|
||||||
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
|
|
||||||
handled?: boolean;
|
|
||||||
severity?: "error" | "warning" | "info";
|
|
||||||
};
|
|
||||||
|
|
||||||
type LovableEvents = {
|
|
||||||
captureException?: (
|
|
||||||
error: unknown,
|
|
||||||
context?: Record<string, unknown>,
|
|
||||||
options?: LovableErrorOptions,
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__lovableEvents?: LovableEvents;
|
|
||||||
__lovableReportRuntimeError?: (payload: {
|
|
||||||
message: string;
|
|
||||||
stack?: string;
|
|
||||||
filename?: string;
|
|
||||||
}) => void;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
window.__lovableEvents?.captureException?.(
|
|
||||||
error,
|
|
||||||
{
|
|
||||||
source: "react_error_boundary",
|
|
||||||
route: window.location.pathname,
|
|
||||||
...context,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mechanism: "react_error_boundary",
|
|
||||||
handled: false,
|
|
||||||
severity: "error",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
|
|
||||||
// editor's telemetry never sees them. Forward to lovable.js's reporting hook,
|
|
||||||
// which is present only inside the editor preview.
|
|
||||||
// Loaders and server fns commonly throw a raw Response; String(it) is the
|
|
||||||
// opaque "[object Response]", so pull out the status and URL instead.
|
|
||||||
const message =
|
|
||||||
error instanceof Response
|
|
||||||
? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: String(error);
|
|
||||||
const stack = error instanceof Error ? error.stack : undefined;
|
|
||||||
window.__lovableReportRuntimeError?.({
|
|
||||||
message,
|
|
||||||
...(stack !== undefined && { stack }),
|
|
||||||
filename: window.location.pathname,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { intestazioniAutenticate } from "./auth";
|
||||||
|
|
||||||
|
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
|
||||||
|
|
||||||
|
async function fetchNotificheAttive(): Promise<Set<string>> {
|
||||||
|
const res = await fetch("/api/public/notifiche-attive", {
|
||||||
|
headers: await intestazioniAutenticate(),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Impossibile leggere le notifiche attive");
|
||||||
|
const { giocatoreIds } = (await res.json()) as { giocatoreIds: string[] };
|
||||||
|
return new Set(giocatoreIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insieme degli id giocatore con almeno un dispositivo iscritto alle notifiche push. */
|
||||||
|
export function useNotificheAttive() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: NOTIFICHE_ATTIVE_KEY,
|
||||||
|
queryFn: fetchNotificheAttive,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invia un messaggio push libero a un giocatore, o a tutti se `giocatoreId` è omesso. */
|
||||||
|
export function useInviaNotifica() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ messaggio, giocatoreId }: { messaggio: string; giocatoreId?: string }) => {
|
||||||
|
const res = await fetch("/api/public/notifica-personalizzata", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
|
||||||
|
body: JSON.stringify({ messaggio, giocatoreId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Impossibile inviare la notifica");
|
||||||
|
return (await res.json()) as { inviate: number; destinatari: number };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** Id giocatore distinti tra le righe di `push_subscriptions` (una riga per dispositivo). */
|
||||||
|
export function idsConNotificheAttive(righe: Array<{ giocatore_id: string }>): string[] {
|
||||||
|
return [...new Set(righe.map((r) => r.giocatore_id))];
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
|
|||||||
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
||||||
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
||||||
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
||||||
|
import { Route as ApiPublicNotificaPersonalizzataRouteImport } from './routes/api/public/notifica-personalizzata'
|
||||||
|
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
|
||||||
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
||||||
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
||||||
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
|
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
|
||||||
@@ -99,6 +101,18 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
|
|||||||
path: '/api/public/csi',
|
path: '/api/public/csi',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiPublicNotificaPersonalizzataRoute =
|
||||||
|
ApiPublicNotificaPersonalizzataRouteImport.update({
|
||||||
|
id: '/api/public/notifica-personalizzata',
|
||||||
|
path: '/api/public/notifica-personalizzata',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiPublicNotificheAttiveRoute =
|
||||||
|
ApiPublicNotificheAttiveRouteImport.update({
|
||||||
|
id: '/api/public/notifiche-attive',
|
||||||
|
path: '/api/public/notifiche-attive',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiPublicPromemoriaPalloniRoute =
|
const ApiPublicPromemoriaPalloniRoute =
|
||||||
ApiPublicPromemoriaPalloniRouteImport.update({
|
ApiPublicPromemoriaPalloniRouteImport.update({
|
||||||
id: '/api/public/promemoria-palloni',
|
id: '/api/public/promemoria-palloni',
|
||||||
@@ -142,6 +156,8 @@ export interface FileRoutesByFullPath {
|
|||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||||
@@ -163,6 +179,8 @@ export interface FileRoutesByTo {
|
|||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||||
@@ -185,6 +203,8 @@ export interface FileRoutesById {
|
|||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||||
@@ -208,6 +228,8 @@ export interface FileRouteTypes {
|
|||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/notifica-personalizzata'
|
||||||
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
| '/api/public/push-subscribe'
|
| '/api/public/push-subscribe'
|
||||||
@@ -229,6 +251,8 @@ export interface FileRouteTypes {
|
|||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/notifica-personalizzata'
|
||||||
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
| '/api/public/push-subscribe'
|
| '/api/public/push-subscribe'
|
||||||
@@ -250,6 +274,8 @@ export interface FileRouteTypes {
|
|||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/notifica-personalizzata'
|
||||||
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
| '/api/public/push-subscribe'
|
| '/api/public/push-subscribe'
|
||||||
@@ -272,6 +298,8 @@ export interface RootRouteChildren {
|
|||||||
PartitaIdRoute: typeof PartitaIdRoute
|
PartitaIdRoute: typeof PartitaIdRoute
|
||||||
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
||||||
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
||||||
|
ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
|
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
|
||||||
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
||||||
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
||||||
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
|
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
|
||||||
@@ -379,6 +407,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/public/notifica-personalizzata': {
|
||||||
|
id: '/api/public/notifica-personalizzata'
|
||||||
|
path: '/api/public/notifica-personalizzata'
|
||||||
|
fullPath: '/api/public/notifica-personalizzata'
|
||||||
|
preLoaderRoute: typeof ApiPublicNotificaPersonalizzataRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/public/notifiche-attive': {
|
||||||
|
id: '/api/public/notifiche-attive'
|
||||||
|
path: '/api/public/notifiche-attive'
|
||||||
|
fullPath: '/api/public/notifiche-attive'
|
||||||
|
preLoaderRoute: typeof ApiPublicNotificheAttiveRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/api/public/promemoria-palloni': {
|
'/api/public/promemoria-palloni': {
|
||||||
id: '/api/public/promemoria-palloni'
|
id: '/api/public/promemoria-palloni'
|
||||||
path: '/api/public/promemoria-palloni'
|
path: '/api/public/promemoria-palloni'
|
||||||
@@ -432,6 +474,8 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
PartitaIdRoute: PartitaIdRoute,
|
PartitaIdRoute: PartitaIdRoute,
|
||||||
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
||||||
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
||||||
|
ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute,
|
||||||
|
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
|
||||||
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
||||||
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
||||||
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
|
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import appCss from "../styles.css?url";
|
import appCss from "../styles.css?url";
|
||||||
import { reportLovableError } from "../lib/lovable-error-reporting";
|
|
||||||
import { BottomNav } from "../components/crapp/BottomNav";
|
import { BottomNav } from "../components/crapp/BottomNav";
|
||||||
import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge";
|
import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
@@ -47,9 +46,6 @@ function NotFoundComponent() {
|
|||||||
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
|
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
useEffect(() => {
|
|
||||||
reportLovableError(error, { boundary: "tanstack_root_error_component" });
|
|
||||||
}, [error]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh items-center justify-center bg-background px-4">
|
<div className="flex min-h-dvh items-center justify-center bg-background px-4">
|
||||||
|
|||||||
+187
-33
@@ -2,6 +2,8 @@ import { useState } from "react";
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
|
Bell,
|
||||||
|
BellOff,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
|
Send,
|
||||||
Unlink,
|
Unlink,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
@@ -16,14 +19,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { Campo, classiInput, PageHeader, Select, StatTile } from "@/components/crapp/ui-bits";
|
||||||
Campo,
|
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||||
classiInput,
|
|
||||||
PageHeader,
|
|
||||||
Section,
|
|
||||||
Select,
|
|
||||||
StatTile,
|
|
||||||
} from "@/components/crapp/ui-bits";
|
|
||||||
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||||
import {
|
import {
|
||||||
nomeCompleto,
|
nomeCompleto,
|
||||||
@@ -54,7 +51,17 @@ import {
|
|||||||
import { oggiISO } from "@/lib/palloni-core";
|
import { oggiISO } from "@/lib/palloni-core";
|
||||||
import { scaricaCsv } from "@/lib/scout-export";
|
import { scaricaCsv } from "@/lib/scout-export";
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
import { useIsAdmin } from "@/lib/ruoli";
|
||||||
|
import { useInviaNotifica, useNotificheAttive } from "@/lib/notifiche-admin";
|
||||||
import { Reveal } from "@/components/motion/Reveal";
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
|
||||||
export const Route = createFileRoute("/admin")({
|
export const Route = createFileRoute("/admin")({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
@@ -232,7 +239,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
</Select>
|
</Select>
|
||||||
</Campo>
|
</Campo>
|
||||||
</div>
|
</div>
|
||||||
<Campo label="Email (collegamento automatico al login, DD-018)">
|
<Campo label="Email">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={squadraCorrente.email ?? ""}
|
value={squadraCorrente.email ?? ""}
|
||||||
@@ -384,13 +391,16 @@ function SchedaGiocatore({
|
|||||||
profilo,
|
profilo,
|
||||||
oggi,
|
oggi,
|
||||||
indice,
|
indice,
|
||||||
|
aperta,
|
||||||
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
g: GiocatoreSquadra;
|
g: GiocatoreSquadra;
|
||||||
profilo: Profilo | undefined;
|
profilo: Profilo | undefined;
|
||||||
oggi: string;
|
oggi: string;
|
||||||
indice: number;
|
indice: number;
|
||||||
|
aperta: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [aperta, setAperta] = useState(false);
|
|
||||||
const nome = nomeCompleto(g);
|
const nome = nomeCompleto(g);
|
||||||
const { ruolo, numero } = g;
|
const { ruolo, numero } = g;
|
||||||
const perc = completamento(profilo);
|
const perc = completamento(profilo);
|
||||||
@@ -401,11 +411,7 @@ function SchedaGiocatore({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
|
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
|
||||||
<button
|
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 text-left">
|
||||||
type="button"
|
|
||||||
onClick={() => setAperta((v) => !v)}
|
|
||||||
className="flex w-full items-center gap-3 text-left"
|
|
||||||
>
|
|
||||||
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm tabular-nums">
|
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm tabular-nums">
|
||||||
{numero}
|
{numero}
|
||||||
</div>
|
</div>
|
||||||
@@ -552,7 +558,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
|||||||
</Select>
|
</Select>
|
||||||
</Campo>
|
</Campo>
|
||||||
</div>
|
</div>
|
||||||
<Campo label="Email (collegamento automatico al login, opzionale)">
|
<Campo label="Email">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={dati.email ?? ""}
|
value={dati.email ?? ""}
|
||||||
@@ -623,7 +629,35 @@ function Dashboard() {
|
|||||||
const admin = useIsAdmin();
|
const admin = useIsAdmin();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
const { profili, isPending } = useProfili();
|
const { profili, isPending } = useProfili();
|
||||||
|
const { data: notificheAttive } = useNotificheAttive();
|
||||||
|
const [schedaAperta, setSchedaAperta] = useState<string | null>(null);
|
||||||
const oggi = oggiISO();
|
const oggi = oggiISO();
|
||||||
|
const inviaNotifica = useInviaNotifica();
|
||||||
|
const [destinatarioMessaggio, setDestinatarioMessaggio] = useState<{
|
||||||
|
id?: string;
|
||||||
|
nome: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [messaggio, setMessaggio] = useState("");
|
||||||
|
|
||||||
|
async function inviaMessaggio() {
|
||||||
|
if (!destinatarioMessaggio || !messaggio.trim()) return;
|
||||||
|
try {
|
||||||
|
const dati = await inviaNotifica.mutateAsync(
|
||||||
|
destinatarioMessaggio.id
|
||||||
|
? { messaggio: messaggio.trim(), giocatoreId: destinatarioMessaggio.id }
|
||||||
|
: { messaggio: messaggio.trim() },
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
dati.inviate > 0
|
||||||
|
? `Notifica inviata a ${dati.inviate} dispositivi`
|
||||||
|
: "Nessun dispositivo con notifiche attive per l'invio",
|
||||||
|
);
|
||||||
|
setDestinatarioMessaggio(null);
|
||||||
|
setMessaggio("");
|
||||||
|
} catch {
|
||||||
|
toast.error("Non sono riuscito a inviare la notifica");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
return (
|
return (
|
||||||
@@ -649,11 +683,8 @@ function Dashboard() {
|
|||||||
).length;
|
).length;
|
||||||
const tesserati = attivi.filter((g) => g.numeroTessera).length;
|
const tesserati = attivi.filter((g) => g.numeroTessera).length;
|
||||||
|
|
||||||
return (
|
const contenutoSquadra = (
|
||||||
<>
|
<>
|
||||||
<PageHeader titolo="Dashboard" sottotitolo="Profili e tesseramento" />
|
|
||||||
|
|
||||||
<Section titolo="Squadra">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<StatTile valore={attivi.length} label="Giocatori" />
|
<StatTile valore={attivi.length} label="Giocatori" />
|
||||||
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
|
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
|
||||||
@@ -666,39 +697,162 @@ function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))}
|
||||||
scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))
|
|
||||||
}
|
|
||||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
||||||
</button>
|
</button>
|
||||||
<AggiungiGiocatore righe={squadra} />
|
<AggiungiGiocatore righe={squadra} />
|
||||||
</Section>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
<Section titolo="Profili" indice={1}>
|
const contenutoProfili = isPending ? (
|
||||||
{isPending ? (
|
|
||||||
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||||
Caricamento…
|
Caricamento…
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{attivi.map((g, i) => (
|
{attivi.map((g, i) => (
|
||||||
<SchedaGiocatore key={g.id} g={g} profilo={profili[g.id]} oggi={oggi} indice={i} />
|
<SchedaGiocatore
|
||||||
|
key={g.id}
|
||||||
|
g={g}
|
||||||
|
profilo={profili[g.id]}
|
||||||
|
oggi={oggi}
|
||||||
|
indice={i}
|
||||||
|
aperta={schedaAperta === g.id}
|
||||||
|
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</Section>
|
|
||||||
|
|
||||||
{disattivi.length > 0 ? (
|
const contenutoDisattivati = (
|
||||||
<Section titolo="Giocatori disattivati" indice={2}>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{disattivi.map((g) => (
|
{disattivi.map((g) => (
|
||||||
<GiocatoreDisattivato key={g.id} g={g} />
|
<GiocatoreDisattivato key={g.id} g={g} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
);
|
||||||
) : null}
|
|
||||||
|
const contenutoNotifiche = notificheAttive ? (
|
||||||
|
<>
|
||||||
|
<StatTile
|
||||||
|
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
|
||||||
|
label="Notifiche attive"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDestinatarioMessaggio({ nome: "tutta la squadra" })}
|
||||||
|
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" /> Invia messaggio a tutti
|
||||||
|
</button>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{attivi.map((g) => {
|
||||||
|
const attiva = notificheAttive.has(g.id);
|
||||||
|
return (
|
||||||
|
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card">
|
||||||
|
{attiva ? (
|
||||||
|
<Bell className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||||
|
) : (
|
||||||
|
<BellOff className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1 truncate text-sm font-semibold leading-tight",
|
||||||
|
!attiva && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{nomeCompleto(g)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDestinatarioMessaggio({ id: g.id, nome: nomeCompleto(g) })}
|
||||||
|
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
|
||||||
|
aria-label={`Invia messaggio a ${nomeCompleto(g)}`}
|
||||||
|
>
|
||||||
|
<Send className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||||
|
Caricamento…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader titolo="Dashboard" sottotitolo="Profili e tesseramento" />
|
||||||
|
|
||||||
|
<BarraSottosezioni
|
||||||
|
defaultId="squadra"
|
||||||
|
variante="sottolineatura"
|
||||||
|
riempiLarghezza
|
||||||
|
voci={[
|
||||||
|
{ id: "squadra", label: "Squadra", contenuto: contenutoSquadra },
|
||||||
|
{ id: "profili", label: "Profili", contenuto: contenutoProfili },
|
||||||
|
...(disattivi.length > 0
|
||||||
|
? [{ id: "disattivati", label: "Disattivati", contenuto: contenutoDisattivati }]
|
||||||
|
: []),
|
||||||
|
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={!!destinatarioMessaggio}
|
||||||
|
onOpenChange={(aperto) => {
|
||||||
|
if (!aperto) {
|
||||||
|
setDestinatarioMessaggio(null);
|
||||||
|
setMessaggio("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>Invia messaggio</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
{destinatarioMessaggio ? `Destinatario: ${destinatarioMessaggio.nome}.` : ""}
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="px-4">
|
||||||
|
<textarea
|
||||||
|
value={messaggio}
|
||||||
|
maxLength={300}
|
||||||
|
rows={3}
|
||||||
|
onChange={(e) => setMessaggio(e.target.value)}
|
||||||
|
placeholder="Scrivi il messaggio da inviare come notifica push…"
|
||||||
|
className={cn(classiInput, "h-auto")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DrawerFooter>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={inviaMessaggio}
|
||||||
|
disabled={inviaNotifica.isPending || !messaggio.trim()}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{inviaNotifica.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
)}{" "}
|
||||||
|
Invia
|
||||||
|
</button>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||||
|
import { inviaPush } from "@/lib/webpush.server";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
messaggio: z.string().min(1).max(300),
|
||||||
|
giocatoreId: z.string().min(1).max(50).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/public/notifica-personalizzata")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
POST: async ({ request }) => {
|
||||||
|
const negato = await richiediAdmin(request);
|
||||||
|
if (negato) return negato;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
|
||||||
|
|
||||||
|
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||||
|
|
||||||
|
let query = supabaseAdmin.from("push_subscriptions").select("endpoint, p256dh, auth");
|
||||||
|
if (parsed.data.giocatoreId) {
|
||||||
|
query = query.eq("giocatore_id", parsed.data.giocatoreId);
|
||||||
|
}
|
||||||
|
const { data: iscrizioni } = await query;
|
||||||
|
|
||||||
|
const titolo = "Messaggio dallo staff";
|
||||||
|
const testo = parsed.data.messaggio.trim();
|
||||||
|
|
||||||
|
let inviate = 0;
|
||||||
|
for (const iscrizione of iscrizioni ?? []) {
|
||||||
|
try {
|
||||||
|
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
||||||
|
if (stato === 404 || stato === 410) {
|
||||||
|
await supabaseAdmin
|
||||||
|
.from("push_subscriptions")
|
||||||
|
.delete()
|
||||||
|
.eq("endpoint", iscrizione.endpoint);
|
||||||
|
} else if (stato >= 200 && stato < 300) {
|
||||||
|
inviate += 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("notifica-personalizzata", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ inviate, destinatari: iscrizioni?.length ?? 0 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||||
|
import { idsConNotificheAttive } from "@/lib/notifiche-attive.server";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/public/notifiche-attive")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: async ({ request }) => {
|
||||||
|
const negato = await richiediAdmin(request);
|
||||||
|
if (negato) return negato;
|
||||||
|
|
||||||
|
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||||
|
const { data, error } = await supabaseAdmin
|
||||||
|
.from("push_subscriptions")
|
||||||
|
.select("giocatore_id");
|
||||||
|
if (error) {
|
||||||
|
console.error("notifiche-attive", error);
|
||||||
|
return new Response("Errore lettura", { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ giocatoreIds: idsConNotificheAttive(data ?? []) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
|||||||
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
||||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
import { useIsAdmin } from "@/lib/ruoli";
|
||||||
|
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
@@ -37,8 +38,6 @@ export const Route = createFileRoute("/calendario")({
|
|||||||
component: Calendario,
|
component: Calendario,
|
||||||
});
|
});
|
||||||
|
|
||||||
const giorniIT = ["L", "M", "M", "G", "V", "S", "D"];
|
|
||||||
|
|
||||||
/** Colore per tipo di evento, usato per dividere le celle con più tipi. */
|
/** Colore per tipo di evento, usato per dividere le celle con più tipi. */
|
||||||
const coloreTipo: Record<Evento["tipo"], string> = {
|
const coloreTipo: Record<Evento["tipo"], string> = {
|
||||||
partita: "var(--accent)",
|
partita: "var(--accent)",
|
||||||
@@ -47,63 +46,6 @@ const coloreTipo: Record<Evento["tipo"], string> = {
|
|||||||
compleanno: "var(--success)",
|
compleanno: "var(--success)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const mesiIT = [
|
|
||||||
"Gennaio",
|
|
||||||
"Febbraio",
|
|
||||||
"Marzo",
|
|
||||||
"Aprile",
|
|
||||||
"Maggio",
|
|
||||||
"Giugno",
|
|
||||||
"Luglio",
|
|
||||||
"Agosto",
|
|
||||||
"Settembre",
|
|
||||||
"Ottobre",
|
|
||||||
"Novembre",
|
|
||||||
"Dicembre",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function useMeseNav(initial?: { anno: number; mese: number }) {
|
|
||||||
const oggi = new Date();
|
|
||||||
const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear());
|
|
||||||
const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth());
|
|
||||||
// Serve a far entrare e uscire la griglia dallo stesso lato del gesto:
|
|
||||||
// se un mese esce a sinistra, il precedente deve rientrare da sinistra.
|
|
||||||
const [direzione, setDirezione] = useState(0);
|
|
||||||
|
|
||||||
const precedente = () => {
|
|
||||||
setDirezione(-1);
|
|
||||||
if (mese === 0) {
|
|
||||||
setMese(11);
|
|
||||||
setAnno((a) => a - 1);
|
|
||||||
} else {
|
|
||||||
setMese((m) => m - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const successivo = () => {
|
|
||||||
setDirezione(1);
|
|
||||||
if (mese === 11) {
|
|
||||||
setMese(0);
|
|
||||||
setAnno((a) => a + 1);
|
|
||||||
} else {
|
|
||||||
setMese((m) => m + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { anno, mese, direzione, precedente, successivo };
|
|
||||||
}
|
|
||||||
|
|
||||||
function giorniDelMese(anno: number, mese: number) {
|
|
||||||
const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate();
|
|
||||||
const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay();
|
|
||||||
const offsetLunedi = (primoGiorno + 6) % 7;
|
|
||||||
return { giorni, offsetLunedi };
|
|
||||||
}
|
|
||||||
|
|
||||||
function pad2(n: number) {
|
|
||||||
return String(n).padStart(2, "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
function Calendario() {
|
function Calendario() {
|
||||||
// SSR-safe: la data di oggi arriva solo dopo il mount.
|
// SSR-safe: la data di oggi arriva solo dopo il mount.
|
||||||
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
|
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
|
||||||
|
|||||||
+208
-19
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
|
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Campo, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits";
|
import { Campo, Card, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
} from "@/components/ui/drawer";
|
} from "@/components/ui/drawer";
|
||||||
import { formatData } from "@/lib/crapp-data";
|
import { formatData } from "@/lib/crapp-data";
|
||||||
|
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||||
import {
|
import {
|
||||||
categoriaEvento,
|
categoriaEvento,
|
||||||
@@ -66,8 +67,19 @@ function GestioneEventi() {
|
|||||||
const elimina = useEliminaEvento();
|
const elimina = useEliminaEvento();
|
||||||
const [bozza, setBozza] = useState<Evento | null>(null);
|
const [bozza, setBozza] = useState<Evento | null>(null);
|
||||||
const [daEliminare, setDaEliminare] = useState<Evento | null>(null);
|
const [daEliminare, setDaEliminare] = useState<Evento | null>(null);
|
||||||
|
const [confermaModifica, setConfermaModifica] = useState(false);
|
||||||
const rosa = squadra.filter((g) => g.attivo);
|
const rosa = squadra.filter((g) => g.attivo);
|
||||||
|
|
||||||
|
// SSR-safe: la data di oggi arriva solo dopo il mount.
|
||||||
|
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const d = new Date();
|
||||||
|
setOggi({ anno: d.getFullYear(), mese: d.getMonth(), giorno: d.getDate() });
|
||||||
|
}, []);
|
||||||
|
const [giornoSelezionato, setGiornoSelezionato] = useState<string | null>(null);
|
||||||
|
const { anno, mese, precedente, successivo } = useMeseNav();
|
||||||
|
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
|
||||||
|
|
||||||
if (!io || !admin) {
|
if (!io || !admin) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -85,18 +97,31 @@ function GestioneEventi() {
|
|||||||
setBozza((b) => (b ? { ...b, ...patch } : b));
|
setBozza((b) => (b ? { ...b, ...patch } : b));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function conferma() {
|
const modificaEsistente = bozza ? eventi.some((e) => e.id === bozza.id) : false;
|
||||||
|
|
||||||
|
function chiediConferma() {
|
||||||
if (!bozza) return;
|
if (!bozza) return;
|
||||||
if (!bozza.titolo.trim()) {
|
if (!bozza.titolo.trim()) {
|
||||||
toast.error("Serve un titolo per l'evento");
|
toast.error("Serve un titolo per l'evento");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (modificaEsistente) {
|
||||||
|
setConfermaModifica(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
conferma();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function conferma() {
|
||||||
|
if (!bozza) return;
|
||||||
try {
|
try {
|
||||||
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() });
|
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() });
|
||||||
toast.success("Evento salvato");
|
toast.success("Evento salvato");
|
||||||
setBozza(null);
|
setBozza(null);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Non sono riuscito a salvare l'evento");
|
toast.error("Non sono riuscito a salvare l'evento");
|
||||||
|
} finally {
|
||||||
|
setConfermaModifica(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,20 +137,106 @@ function GestioneEventi() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
|
||||||
|
const eventiPerGiorno = new Map<number, Evento[]>();
|
||||||
|
for (const e of eventi) {
|
||||||
|
if (!e.data.startsWith(mesePrefix)) continue;
|
||||||
|
const g = Number(e.data.slice(8, 10));
|
||||||
|
const lista = eventiPerGiorno.get(g) ?? [];
|
||||||
|
lista.push(e);
|
||||||
|
eventiPerGiorno.set(g, lista);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apriGiorno(giorno: number) {
|
||||||
|
setGiornoSelezionato(`${mesePrefix}-${pad2(giorno)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nuovoNelGiorno() {
|
||||||
|
if (!giornoSelezionato) return;
|
||||||
|
setBozza({ ...eventoVuoto(), data: giornoSelezionato });
|
||||||
|
setGiornoSelezionato(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modificaDalGiorno(e: Evento) {
|
||||||
|
setBozza(e);
|
||||||
|
setGiornoSelezionato(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eliminaDalGiorno(e: Evento) {
|
||||||
|
setDaEliminare(e);
|
||||||
|
setGiornoSelezionato(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non usa `eventiPerGiorno` (limitato al mese in vista sul calendario): il giorno
|
||||||
|
// selezionato può arrivare anche dalla lista sotto, per un mese diverso da quello mostrato.
|
||||||
|
const eventiGiornoSelezionato = giornoSelezionato
|
||||||
|
? eventi.filter((e) => e.data === giornoSelezionato)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader titolo="Gestione eventi" sottotitolo={`${eventi.length} eventi in calendario`} />
|
<PageHeader titolo="Gestione eventi" sottotitolo={`${eventi.length} eventi in calendario`} />
|
||||||
|
|
||||||
<div className="px-5 pt-4">
|
<Section titolo="Calendario">
|
||||||
|
<Card>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setBozza(eventoVuoto())}
|
onClick={precedente}
|
||||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
|
||||||
|
aria-label="Mese precedente"
|
||||||
>
|
>
|
||||||
<CalendarPlus className="h-4 w-4" /> Nuovo evento
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<span className="font-display-sm text-xl uppercase">
|
||||||
|
{mesiIT[mese]} {anno}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={successivo}
|
||||||
|
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
|
||||||
|
aria-label="Mese successivo"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
|
||||||
|
{giorniIT.map((g, i) => (
|
||||||
|
<span key={i}>{g}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 grid grid-cols-7 gap-1">
|
||||||
|
{Array.from({ length: offsetLunedi }).map((_, i) => (
|
||||||
|
<span key={`v${i}`} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: giorni }).map((_, i) => {
|
||||||
|
const giorno = i + 1;
|
||||||
|
const eventiGiorno = eventiPerGiorno.get(giorno) ?? [];
|
||||||
|
const haEventi = eventiGiorno.length > 0;
|
||||||
|
const isOggi =
|
||||||
|
!!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={giorno}
|
||||||
|
type="button"
|
||||||
|
onClick={() => apriGiorno(giorno)}
|
||||||
|
className={cn(
|
||||||
|
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold transition-transform active:scale-90",
|
||||||
|
haEventi ? "bg-accent text-accent-foreground" : "bg-secondary text-foreground",
|
||||||
|
isOggi && "ring-2 ring-foreground ring-offset-1 ring-offset-card",
|
||||||
|
)}
|
||||||
|
aria-label={`${giorno} ${mesiIT[mese]}${haEventi ? `: ${eventiGiorno.length} ${eventiGiorno.length === 1 ? "evento" : "eventi"}` : ", nessun evento"}`}
|
||||||
|
aria-current={isOggi ? "date" : undefined}
|
||||||
|
>
|
||||||
|
{giorno}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{bozza ? (
|
{bozza ? (
|
||||||
<Section
|
<Section
|
||||||
titolo={eventi.some((e) => e.id === bozza.id) ? "Modifica evento" : "Nuovo evento"}
|
titolo={eventi.some((e) => e.id === bozza.id) ? "Modifica evento" : "Nuovo evento"}
|
||||||
@@ -154,7 +265,6 @@ function GestioneEventi() {
|
|||||||
value={bozza.titolo}
|
value={bozza.titolo}
|
||||||
maxLength={80}
|
maxLength={80}
|
||||||
onChange={(e) => aggiorna({ titolo: e.target.value })}
|
onChange={(e) => aggiorna({ titolo: e.target.value })}
|
||||||
placeholder="Es. CRAP Volley vs Aurora Nera"
|
|
||||||
className={classiInput}
|
className={classiInput}
|
||||||
/>
|
/>
|
||||||
</Campo>
|
</Campo>
|
||||||
@@ -272,7 +382,7 @@ function GestioneEventi() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={conferma}
|
onClick={chiediConferma}
|
||||||
disabled={salva.isPending}
|
disabled={salva.isPending}
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -305,9 +415,11 @@ function GestioneEventi() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{eventi.map((e) => (
|
{eventi.map((e) => (
|
||||||
<div
|
<button
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className="flex items-center gap-2 rounded-3xl bg-card p-3 shadow-card"
|
type="button"
|
||||||
|
onClick={() => setGiornoSelezionato(e.data)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-3xl bg-card p-3 text-left shadow-card active:scale-[0.99]"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
||||||
@@ -315,27 +427,104 @@ function GestioneEventi() {
|
|||||||
{formatData(e.data)} · {e.ora} · {e.luogo || "luogo da definire"}
|
{formatData(e.data)} · {e.ora} · {e.luogo || "luogo da definire"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={!!giornoSelezionato}
|
||||||
|
onOpenChange={(aperto) => !aperto && setGiornoSelezionato(null)}
|
||||||
|
>
|
||||||
|
<DrawerContent className="rounded-t-[24px] border-border bg-background px-4 pb-6 pt-2">
|
||||||
|
<DrawerHeader className="relative px-0 pb-2 text-left">
|
||||||
|
<DrawerTitle className="font-display-lg text-2xl uppercase">
|
||||||
|
{giornoSelezionato ? formatData(giornoSelezionato) : "Giorno"}
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerClose className="absolute right-0 top-0 grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground transition-transform active:scale-90">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Chiudi</span>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="max-h-[50vh] space-y-2 overflow-y-auto py-2">
|
||||||
|
{eventiGiornoSelezionato.length > 0 ? (
|
||||||
|
eventiGiornoSelezionato.map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{e.ora} · {e.luogo || "luogo da definire"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setBozza(e)}
|
onClick={() => modificaDalGiorno(e)}
|
||||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
|
||||||
aria-label={`Modifica ${e.titolo}`}
|
aria-label={`Modifica ${e.titolo}`}
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDaEliminare(e)}
|
onClick={() => eliminaDalGiorno(e)}
|
||||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95"
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95"
|
||||||
aria-label={`Elimina ${e.titolo}`}
|
aria-label={`Elimina ${e.titolo}`}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
</div>
|
) : (
|
||||||
|
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
||||||
|
Nessun evento in programma
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={nuovoNelGiorno}
|
||||||
|
className="premi mt-1 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
|
>
|
||||||
|
<CalendarPlus className="h-4 w-4" /> Nuovo evento in questo giorno
|
||||||
|
</button>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={confermaModifica}
|
||||||
|
onOpenChange={(aperto) => !aperto && setConfermaModifica(false)}
|
||||||
|
>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>Salvare le modifiche?</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
{bozza ? `Aggiorni l'evento "${bozza.titolo.trim() || "senza titolo"}".` : ""}
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<DrawerFooter>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={conferma}
|
||||||
|
disabled={salva.isPending}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Conferma
|
||||||
|
</button>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
<Drawer open={!!daEliminare} onOpenChange={(aperto) => !aperto && setDaEliminare(null)}>
|
<Drawer open={!!daEliminare} onOpenChange={(aperto) => !aperto && setDaEliminare(null)}>
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
|
|||||||
@@ -230,6 +230,13 @@ try {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prova("l'invio di un messaggio personalizzato chiede le credenziali", async () => {
|
||||||
|
assert.equal(
|
||||||
|
(await postJson("/api/public/notifica-personalizzata", { messaggio: "prova" })).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await prova("un token malformato non passa", async () => {
|
await prova("un token malformato non passa", async () => {
|
||||||
const res = await fetch(url("/api/public/sollecita-presenze"), {
|
const res = await fetch(url("/api/public/sollecita-presenze"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Chi può leggere l'elenco dei giocatori con notifiche push attive:
|
||||||
|
* `bun test/integration/notifiche-attive-route.test.ts`.
|
||||||
|
*
|
||||||
|
* `/api/public/notifiche-attive` legge `push_subscriptions` con la service role e
|
||||||
|
* salta la RLS (come le route che mandano notifiche, DD-024): il permesso deve stare
|
||||||
|
* nella route. Serve un database vero per provare token di un giocatore normale e di
|
||||||
|
* un amministratore, quindi solo stack locale.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { statoLocale } from "../helpers/locale";
|
||||||
|
import { avviaServer, json } from "../helpers/server";
|
||||||
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
|
const locale = statoLocale();
|
||||||
|
|
||||||
|
if (!locale) {
|
||||||
|
salta("notifiche attive: permessi route", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("notifiche-attive-route");
|
||||||
|
} else {
|
||||||
|
const { url: SUPABASE, anon: ANON, servizio: SERVIZIO } = locale;
|
||||||
|
|
||||||
|
// Il server di sviluppo eredita queste: le route leggono i nomi senza prefisso.
|
||||||
|
process.env["SUPABASE_URL"] = SUPABASE;
|
||||||
|
process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
|
||||||
|
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
|
||||||
|
|
||||||
|
const PASSWORD = "prova-notifiche-123";
|
||||||
|
const idUtenti: string[] = [];
|
||||||
|
|
||||||
|
const authAdmin = { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` };
|
||||||
|
|
||||||
|
async function creaUtente(email: string): Promise<string> {
|
||||||
|
const res = await fetch(`${SUPABASE}/auth/v1/admin/users`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...authAdmin, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
|
||||||
|
});
|
||||||
|
const corpo = (await res.json()) as { id?: string };
|
||||||
|
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
|
||||||
|
idUtenti.push(corpo.id);
|
||||||
|
return corpo.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accedi(email: string): Promise<string> {
|
||||||
|
const res = await fetch(`${SUPABASE}/auth/v1/token?grant_type=password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { apikey: ANON, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password: PASSWORD }),
|
||||||
|
});
|
||||||
|
const corpo = (await res.json()) as { access_token?: string };
|
||||||
|
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
|
||||||
|
return corpo.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailGiocatore = `test-notifiche-giocatore-${Date.now()}@example.test`;
|
||||||
|
const emailAdmin = `test-notifiche-admin-${Date.now()}@example.test`;
|
||||||
|
await creaUtente(emailGiocatore);
|
||||||
|
const idAdmin = await creaUtente(emailAdmin);
|
||||||
|
await fetch(`${SUPABASE}/rest/v1/user_roles`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...authAdmin, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenGiocatore = await accedi(emailGiocatore);
|
||||||
|
const tokenAdmin = await accedi(emailAdmin);
|
||||||
|
|
||||||
|
const server = await avviaServer();
|
||||||
|
console.log(`notifiche-attive-route su ${server.baseUrl} (database ${SUPABASE})`);
|
||||||
|
|
||||||
|
const PERCORSO = "/api/public/notifiche-attive";
|
||||||
|
const chiama = (intestazioni: Record<string, string> = {}) =>
|
||||||
|
fetch(`${server.baseUrl}${PERCORSO}`, { headers: intestazioni });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova("senza token la route non risponde", async () => {
|
||||||
|
assert.equal((await chiama()).status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("un giocatore autenticato non è admin", async () => {
|
||||||
|
const res = await chiama({ authorization: `Bearer ${tokenGiocatore}` });
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("un amministratore riceve l'elenco degli id", async () => {
|
||||||
|
const res = await chiama({ authorization: `Bearer ${tokenAdmin}` });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const corpo = (await json(res)) as { giocatoreIds: unknown };
|
||||||
|
assert.ok(Array.isArray(corpo.giocatoreIds), "giocatoreIds è un array");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
server.stop();
|
||||||
|
for (const id of idUtenti) {
|
||||||
|
await fetch(`${SUPABASE}/rest/v1/user_roles?user_id=eq.${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: authAdmin,
|
||||||
|
});
|
||||||
|
await fetch(`${SUPABASE}/auth/v1/admin/users/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: authAdmin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
riepilogo("notifiche-attive-route");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,31 @@ if (!locale) {
|
|||||||
assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`);
|
assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Messaggio libero: stesso controllo d'accesso, corpo diverso. */
|
||||||
|
const chiamaMessaggio = (intestazioni: Record<string, string> = {}) =>
|
||||||
|
fetch(`${server.baseUrl}/api/public/notifica-personalizzata`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", ...intestazioni },
|
||||||
|
body: JSON.stringify({ messaggio: "prova" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("senza token non si manda un messaggio personalizzato", async () => {
|
||||||
|
assert.equal((await chiamaMessaggio()).status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("un giocatore autenticato non manda messaggi personalizzati", async () => {
|
||||||
|
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenGiocatore}` });
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("un amministratore manda il messaggio (nessun dispositivo iscritto)", async () => {
|
||||||
|
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAdmin}` });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const corpo = (await res.json()) as { inviate: number; destinatari: number };
|
||||||
|
assert.equal(corpo.destinatari, 0);
|
||||||
|
assert.equal(corpo.inviate, 0);
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
server.stop();
|
server.stop();
|
||||||
for (const id of idUtenti) {
|
for (const id of idUtenti) {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** Check delle funzioni pure della griglia mensile: `bun test/unit/calendario.test.ts`. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { giorniDelMese, pad2 } from "@/lib/calendario";
|
||||||
|
|
||||||
|
// --- pad2 --------------------------------------------------------------
|
||||||
|
assert.equal(pad2(1), "01");
|
||||||
|
assert.equal(pad2(9), "09");
|
||||||
|
assert.equal(pad2(10), "10");
|
||||||
|
assert.equal(pad2(31), "31");
|
||||||
|
|
||||||
|
// --- giorniDelMese -------------------------------------------------------
|
||||||
|
// Febbraio 2026 (non bisestile): 28 giorni, inizia di domenica -> offset 6.
|
||||||
|
assert.deepEqual(giorniDelMese(2026, 1), { giorni: 28, offsetLunedi: 6 });
|
||||||
|
|
||||||
|
// Febbraio 2028 (bisestile): 29 giorni.
|
||||||
|
assert.deepEqual(giorniDelMese(2028, 1), { giorni: 29, offsetLunedi: 1 });
|
||||||
|
|
||||||
|
// Settembre 2026: 30 giorni, il 1° settembre 2026 è martedì -> offset 1.
|
||||||
|
assert.deepEqual(giorniDelMese(2026, 8), { giorni: 30, offsetLunedi: 1 });
|
||||||
|
|
||||||
|
// Gennaio 2027: il 1° gennaio è venerdì -> offset 4.
|
||||||
|
assert.deepEqual(giorniDelMese(2027, 0), { giorni: 31, offsetLunedi: 4 });
|
||||||
|
|
||||||
|
console.log("calendario: ok");
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* Check della segnalazione errori verso l'editor Lovable: `bun test/unit/lovable-error-reporting.test.ts`.
|
|
||||||
* Fuori dal browser (nessun `window`) deve essere un no-op silenzioso; qui simuliamo
|
|
||||||
* anche un `window` minimale per verificare cosa viene inoltrato al hook di reporting.
|
|
||||||
*/
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { reportLovableError } from "@/lib/lovable-error-reporting";
|
|
||||||
|
|
||||||
// --- fuori dal browser: nessun window, nessun crash ---------------------------
|
|
||||||
assert.equal(typeof window, "undefined", "il test gira in ambiente server, senza DOM");
|
|
||||||
assert.doesNotThrow(() => reportLovableError(new Error("boom")));
|
|
||||||
|
|
||||||
// --- con un window simulato: inoltra al hook dell'editor ----------------------
|
|
||||||
type Riportato = { message: string; stack?: string; filename?: string };
|
|
||||||
let riportato: Riportato | undefined;
|
|
||||||
const finto = {
|
|
||||||
location: { pathname: "/rosa" },
|
|
||||||
__lovableReportRuntimeError: (payload: Riportato) => {
|
|
||||||
riportato = payload;
|
|
||||||
},
|
|
||||||
} as unknown as Window & typeof globalThis;
|
|
||||||
|
|
||||||
(globalThis as { window?: unknown }).window = finto;
|
|
||||||
try {
|
|
||||||
reportLovableError(new Error("qualcosa è andato storto"));
|
|
||||||
assert.ok(riportato, "il payload è stato inoltrato");
|
|
||||||
assert.equal(riportato!.message, "qualcosa è andato storto");
|
|
||||||
assert.equal(riportato!.filename, "/rosa");
|
|
||||||
assert.ok(riportato!.stack, "include lo stack per un Error");
|
|
||||||
|
|
||||||
// Una Response non ha un messaggio leggibile: si usa status + url.
|
|
||||||
riportato = undefined;
|
|
||||||
reportLovableError(new Response(null, { status: 404 }));
|
|
||||||
assert.equal(riportato!.message, "Response 404");
|
|
||||||
assert.equal(riportato!.stack, undefined, "una Response non ha stack");
|
|
||||||
|
|
||||||
// Un valore qualunque diventa la sua stringa.
|
|
||||||
riportato = undefined;
|
|
||||||
reportLovableError("motivo generico");
|
|
||||||
assert.equal(riportato!.message, "motivo generico");
|
|
||||||
} finally {
|
|
||||||
delete (globalThis as { window?: unknown }).window;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(typeof window, "undefined", "il window simulato non è rimasto in giro");
|
|
||||||
|
|
||||||
console.log("lovable-error-reporting: ok");
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Check della deduplica per la sezione Notifiche in admin:
|
||||||
|
* `bun test/unit/notifiche-attive.test.ts`.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { idsConNotificheAttive } from "@/lib/notifiche-attive.server";
|
||||||
|
|
||||||
|
assert.deepEqual(idsConNotificheAttive([]), [], "nessuna riga -> nessun id");
|
||||||
|
|
||||||
|
assert.deepEqual(idsConNotificheAttive([{ giocatore_id: "g1" }]), ["g1"], "una riga -> un id");
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
idsConNotificheAttive([{ giocatore_id: "g1" }, { giocatore_id: "g1" }, { giocatore_id: "g2" }]),
|
||||||
|
["g1", "g2"],
|
||||||
|
"più dispositivi dello stesso giocatore contano una volta sola",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
idsConNotificheAttive([{ giocatore_id: "g2" }, { giocatore_id: "g1" }]),
|
||||||
|
["g2", "g1"],
|
||||||
|
"ordine di prima comparsa, non alfabetico",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("notifiche-attive: ok");
|
||||||
+87
-9
@@ -1,15 +1,93 @@
|
|||||||
// @lovable.dev/vite-tanstack-config already includes the following — do NOT add them manually
|
import { defineConfig, loadEnv, mergeConfig } from "vite";
|
||||||
// or the app will break with duplicate plugins:
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
// - TanStack devtools (dev-only, first), tanstackStart, viteReact, tailwindcss, tsConfigPaths,
|
import tsConfigPaths from "vite-tsconfig-paths";
|
||||||
// nitro (build-only using cloudflare as a default target), VITE_* env injection, @ path alias,
|
import viteReact from "@vitejs/plugin-react";
|
||||||
// React/TanStack dedupe, error logger plugins, and sandbox detection (port/host/strictPort).
|
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||||
// You can pass additional config via defineConfig({ vite: { ... }, etc... }) if needed.
|
import { devtools } from "@tanstack/devtools-vite";
|
||||||
import { defineConfig } from "@lovable.dev/vite-tanstack-config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(async ({ command, mode }) => {
|
||||||
tanstackStart: {
|
const isDevBuild = command === "build" && mode === "development";
|
||||||
|
|
||||||
|
const loadedEnv = loadEnv(mode, process.cwd(), "VITE_");
|
||||||
|
const envDefine: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(loadedEnv)) {
|
||||||
|
envDefine[`import.meta.env.${key}`] = JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins = [];
|
||||||
|
if (mode === "development") {
|
||||||
|
plugins.push(
|
||||||
|
devtools({
|
||||||
|
logging: false,
|
||||||
|
eventBusConfig: { enabled: false },
|
||||||
|
enhancedLogs: { enabled: false },
|
||||||
|
consolePiping: { enabled: false },
|
||||||
|
removeDevtoolsOnBuild: false,
|
||||||
|
injectSource: { enabled: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
plugins.push(tailwindcss());
|
||||||
|
plugins.push(tsConfigPaths({ projects: ["./tsconfig.json"] }));
|
||||||
|
plugins.push(
|
||||||
|
tanstackStart({
|
||||||
|
importProtection: {
|
||||||
|
behavior: "error",
|
||||||
|
client: { files: ["**/server/**"], specifiers: ["server-only"] },
|
||||||
|
},
|
||||||
// Redirect TanStack Start's bundled server entry to src/server.ts (our SSR error wrapper).
|
// Redirect TanStack Start's bundled server entry to src/server.ts (our SSR error wrapper).
|
||||||
// nitro/vite builds from this
|
// nitro/vite builds from this
|
||||||
server: { entry: "server" },
|
server: { entry: "server" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (command === "build") {
|
||||||
|
const { nitro } = await import("nitro/vite");
|
||||||
|
plugins.push(nitro({ defaultPreset: "cloudflare-module" }));
|
||||||
|
}
|
||||||
|
plugins.push(viteReact());
|
||||||
|
|
||||||
|
let config = {
|
||||||
|
define: envDefine,
|
||||||
|
...(isDevBuild
|
||||||
|
? {
|
||||||
|
environments: {
|
||||||
|
client: { define: { "process.env.NODE_ENV": JSON.stringify("development") } },
|
||||||
|
},
|
||||||
|
esbuild: { keepNames: true },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
css: { transformer: "lightningcss" as const },
|
||||||
|
resolve: {
|
||||||
|
alias: { "@": `${process.cwd()}/src` },
|
||||||
|
dedupe: [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react/jsx-runtime",
|
||||||
|
"react/jsx-dev-runtime",
|
||||||
|
"@tanstack/react-query",
|
||||||
|
"@tanstack/query-core",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react-dom/client",
|
||||||
|
"react/jsx-runtime",
|
||||||
|
"react/jsx-dev-runtime",
|
||||||
|
],
|
||||||
|
ignoreOutdatedRequests: true,
|
||||||
|
},
|
||||||
|
plugins,
|
||||||
|
};
|
||||||
|
|
||||||
|
config = mergeConfig(config, {
|
||||||
|
server: {
|
||||||
|
host: "::",
|
||||||
|
port: 8080,
|
||||||
|
watch: { awaitWriteFinish: { stabilityThreshold: 1000, pollInterval: 100 } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user