diff --git a/README.md b/README.md index 6527d43..169f53b 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,10 @@ ## Documentazione -Vedi `readme.txt` per changelog e note GDPR (formato WordPress.org). -Vedi `deploy/wiki-tonitalia-pluginsoci.md` per la guida operativa completa. +- Changelog e note GDPR: `readme.txt` +- Guida operativa completa: [WIKI.md](WIKI.md) +- Download zip release: [releases/README.md](releases/README.md) -## Versione +## Versione attuale -Vedi intestazione in `ton-italia-registration.php` (`TON_REG_VERSION`). +**1.8.2** — vedi `TON_REG_VERSION` in `ton-italia-registration.php`. diff --git a/WIKI.md b/WIKI.md new file mode 100644 index 0000000..3d6886e --- /dev/null +++ b/WIKI.md @@ -0,0 +1,435 @@ +# Wiki — Plugin TON Italia Registration + +**Repository:** `https://niphredil.duckdns.org/git/fabio/tonitalia-pluginsoci.git` +**Cartella WordPress:** `wp-content/plugins/ton-italia-registration/` +**Versione attuale:** 1.8.2 +**Organizzazione:** TON ITALIA ODV + +--- + +## 1. Scopo del plugin + +Gestisce il processo di **domanda di ammissione a socio e tesseramento** TON ITALIA ODV: + +- Form pubblico fedele al modulo PDF (campi anagrafici, dichiarazioni, privacy). +- Creazione utente WordPress **Subscriber** (senza accesso wp-admin). +- Notifiche email admin e richiedente. +- Pannello back-office per ammissione, documenti e GDPR. +- CAPTCHA integrato (nessun servizio esterno). + +--- + +## 2. Requisiti + +| Requisito | Valore | +|-----------|--------| +| WordPress | 6.0+ | +| PHP | 8.0+ (8.1+ consigliato in produzione Aruba) | +| Permessi admin | `manage_options` per gestione iscrizioni | +| SMTP / mail | `wp_mail` funzionante (hosting o plugin SMTP) | + +--- + +## 3. Installazione + +1. Copiare `ton-italia-registration` in `wp-content/plugins/`. +2. **Plugin → Plugin installati → Attiva** “TON Italia Registration”. +3. All’attivazione: creazione tabella DB `{prefisso}ton_registrations`, cartella upload `Documenti Iscritti`, opzioni default (testi da PDF). +4. Creare pagina (es. “Iscrizione socio”) e inserire: + +``` +[ton_registration_form] +``` + +5. **TON Iscrizioni → Impostazioni**: verificare testi, IBAN, email, CAPTCHA. + +### Attributo shortcode opzionale + +``` +[ton_registration_form class="mia-classe-css"] +``` + +Aggiunge una classe al wrapper `.ton-reg-form` per stili custom nel tema. + +--- + +## 4. Flusso utente (frontend) + +```mermaid +flowchart TD + A[Utente apre pagina con shortcode] --> B[Compila form] + B --> C[CAPTCHA + validazione] + C --> D{OK?} + D -->|No| E[Messaggio errore sulla pagina] + D -->|Sì| F[Crea utente Subscriber] + F --> G[Salva riga DB + meta] + G --> H[Email admin + email utente] + H --> I[Messaggio successo] +``` + +### 4.1 Sezioni del form (ordine) + +1. **Quota associativa** — testo informativo (bonifico, IBAN, quota) da impostazioni; checkbox opzionale “Ho effettuato il bonifico”. +2. **Dati anagrafici** — cognome, nome, nascita, CF, residenza, telefono, email. +3. **Dichiarazione** — testo statuto (editabile), luogo/data, checkbox accettazione (sostitutivo firma). +4. **Privacy** — informativa breve, checkbox privacy obbligatoria, newsletter (opzionale o obbligatoria da settings). +5. **Verifica anti-spam** — se CAPTCHA attivo: somma matematica + honeypot. + +### 4.2 Formato date + +Le date (**nascita** e **dichiarazione**) sono in formato **gg-mm-aaaa** (es. `25-05-1990`). +In database vengono salvate come `aaaa-mm-gg`. + +### 4.3 Dopo l’invio + +- Redirect alla stessa pagina con `?ton_reg=success` (messaggio HTML da impostazioni). +- Creazione account con **email** univoca; login generato dalla parte locale dell’email (`mario`, `mario1`, … se collisioni). +- Email utente con link **reimposta password** (WordPress standard). +- Ruolo: **subscriber** — bloccato su `/wp-admin/` e barra admin nascosta. + +--- + +## 5. Sicurezza form + +| Meccanismo | Descrizione | +|------------|-------------| +| Nonce WordPress | `ton_reg_nonce` su ogni submit | +| Honeypot | Campo nascosto `ton_reg_website` — se compilato, rifiuto | +| Tempo minimo | Form non inviabile prima di ~3 secondi dal caricamento | +| CAPTCHA matematico | Domanda casuale (es. 4+7), risposta in transient legata al token | +| Rate limit IP | Default: max 5 tentativi / 15 min (impostazioni) | +| Validazione server | CF, CAP 5 cifre, province 2 lettere, email, date, checkbox obbligatori | +| Duplicati | Blocco se email o CF già in iscrizione `pending` o `admitted` | + +--- + +## 6. Database + +### Tabella `{prefisso}ton_registrations` + +Contiene tutti i campi del form + metadati legali: + +| Campo | Note | +|-------|------| +| `registration_ip` | IP al momento dell’invio (finalità legali) | +| `user_agent` | Troncato a 255 caratteri | +| `privacy_text_version` | Versione informativa al consenso | +| `consent_statuto_at`, `consent_privacy_at` | Timestamp UTC | +| `consenso_statuto`, `consenso_privacy`, `consenso_newsletter` | 0/1 | +| `status` | `pending` \| `admitted` \| `rejected` | +| `libro_associati_date` | Solo admin — data Libro Associati | +| `doc_identita_id`, `doc_bonifico_id`, `doc_pagamento_id` | ID attachment Media | +| `admin_notes`, `gdpr_log` | Note e log azioni GDPR | +| `anonymized_at` | Se anonimizzato | + +Mirror parziale anche in `user_meta` con prefisso `ton_reg_*`. + +### Tabella `{prefisso}ton_reg_membership_cards` (v1.7+) + +Tessere PDF generate per `(registration_id, year)`. + +### Tabella `{prefisso}ton_reg_membership_renewals` (v1.8+) + +Quote associative per anno: `status` (`pending` \| `paid`), timestamp promemoria gennaio. + +--- + +## 7. Pannello admin — TON Iscrizioni + +Menu WordPress: **TON Iscrizioni** (icona groups). + +### 7.1 Elenco + +- Filtri: Tutti / In attesa / Ammessi / Non ammessi / **Quota da pagare**. +- Ricerca per nome, email, codice fiscale. +- Colonne: ID, cognome, nome, email, stato, **Rinnovo** (quota anno corrente), data. +- Link **Dettaglio** per ogni riga. +- Pulsante **Esporta CSV Libro Soci**: scarica tutti i soci **ammessi** (esclusi anonimizzati) in CSV con anagrafica, contatti, data iscrizione e data Libro Associati. File UTF-8 con separatore `;` per Excel italiano. + +### 7.2 Dettaglio singola iscrizione + +Barra navigazione: **Socio precedente** / **Socio successivo** (mantiene filtri e ordinamento dell'elenco). + +**Documenti** (solo backend, upload admin): + +| Tipo | Colonna DB | Formati | +|------|------------|---------| +| Documento di identità | `doc_identita_id` | PDF, JPG, PNG | +| Ricevuta versamento tessera | `doc_pagamento_id` | PDF, JPG, PNG | + +File salvati in Media WordPress, percorso fisico: + +``` +wp-content/uploads/Documenti Iscritti/{nome-cognome-codicefiscale}/file.pdf +``` + +La cartella base viene creata all’**attivazione** plugin; la sottocartella iscritto alla prima iscrizione o al primo upload. + +**Tessere PDF (v1.7+)** — per soci ammessi: + +- Generazione automatica al passaggio in stato **Ammesso**. +- Template PDF annuale configurabile in **TON Iscrizioni → Tessere**. +- Batch «Genera tessere nuovo anno» per tutti gli ammessi. +- PDF in `Documenti Iscritti/{socio}/tessere/`. + +**Quote associative annuali (v1.8+)** — checkbox «Pagata» per ogni anno solare, dall'anno iscrizione all'anno in corso (l'anno successivo compare a gennaio). + +**Consensi e dichiarazioni** (sezione dedicata): + +- Dichiarazione statuto — Sì/No + data/ora registrata. +- Privacy e trattamento dati — Sì/No + data/ora + versione informativa. +- Newsletter — accettato o non espresso. +- Bonifico dichiarato — checkbox form opzionale. +- Luogo e data dichiarazione. + +**Dati anagrafici** — tabella read-only dei campi inviati. + +**Gestione stato (Consiglio Direttivo):** + +- Stato: In attesa / **Ammesso** / **Non ammesso**. +- Data Libro Associati. +- Note admin. + +**GDPR:** + +- Esporta JSON / CSV. +- Anonimizza (mantiene record, cancella PII). +- Elimina definitivamente (utente WP + riga DB). + +### 7.3 Impostazioni + +| Gruppo | Opzioni | +|--------|---------| +| Quota | Anno sociale, importo €, beneficiario, IBAN | +| Testi form | Istruzioni bonifico (HTML), dichiarazione statuto, privacy, newsletter, minorenni, messaggio successo | +| Email | Oggetto/corpo admin e utente; email promemoria rinnovo (1 e 25 gennaio); riepilogo admin rinnovi; email notifiche; contatto GDPR | +| Comportamento | CAPTCHA on/off, newsletter obbligatoria, rate limit, elimina utenti alla disinstallazione | +| Mailchimp | Integrazione attiva, API key, List ID (Audience), tag (es. «Socio TON ITALIA»), test connessione | + +### 7.4 Integrazione Mailchimp (v1.6+) + +Quando un richiedente spunta il **consenso newsletter** nel form, l’email viene sincronizzata con Mailchimp (solo se l’integrazione è attiva e configurata). L’iscrizione socio **non** viene mai bloccata da errori Mailchimp. + +**Configurazione:** + +1. Mailchimp → Account → Extras → **API keys** → crea chiave. +2. Audience → Settings → **Audience ID** (List ID). +3. In **TON Iscrizioni → Impostazioni → Integrazione Mailchimp**: attiva, inserisci API key e List ID, salva. +4. Clic **Test connessione Mailchimp** (verifica credenziali e memorizza eventuali marketing permissions GDPR della lista). + +**Comportamento tecnico:** + +- Sync asincrona (WP-Cron) dopo invio form riuscito. +- Upsert sicuro: `status_if_new: subscribed` (non re-iscrive contatti unsubscribed senza nuovo consenso). +- Se l’utente si è disiscritto in passato ma spunta di nuovo la newsletter sul form TON, viene ripristinato `subscribed`. +- Contatti in stato `cleaned` (bounce): errore in admin, gestione manuale. +- Tag applicati via API dedicata (max 5, separati da virgola nelle impostazioni). + +**Dettaglio iscrizione:** sotto consenso newsletter compare lo stato sync (`pending`, `synced`, `error`) con pulsante **Riprova iscrizione Mailchimp** se necessario. + +**Sicurezza:** API key salvata con `autoload = no`, campo password in admin (lasciare vuoto per non modificare), accesso solo a `manage_options`. + +**Placeholder email:** `{nome}`, `{cognome}`, `{email}`, `{codice_fiscale}`, `{ip}`, `{login_url}`, `{gdpr_contact}`, `{admin_url}` + +**Placeholder istruzioni bonifico:** `{quota}`, `{anno}`, `{beneficiario}`, `{iban}`, `{nome}`, `{cognome}` + +In cima alla pagina: istruzioni per shortcode `[ton_registration_form]`. + +### 7.5 Profilo socio (v1.7.1+) + +I Subscriber accedono solo a **Profilo** in wp-admin (`profile.php`) e possono scaricare la tessera PDF dell'anno. + +- Placeholder email `{profile_url}`. +- Shortcode legacy `[ton_registration_profile]` mantenuto per retrocompatibilità. + +--- + +## 8. Email + +### Admin + +Inviata a `ton_reg_admin_email` (default: email amministratore sito). +Contiene riepilogo e link alla scheda iscrizione in admin. + +### Utente + +Inviata all’email del form. +Include istruzioni bonifico, contatto GDPR, invito a impostare password via `wp_lostpassword_url()`. +Viene anche inviata la notifica WordPress standard nuovo utente (`wp_send_new_user_notifications`). + +**Nota:** configurare SPF/DKIM su Aruba o usare plugin SMTP se le mail non arrivano. + +### Rinnovo quota annuale (v1.8+) + +La tessera associativa copre l'**anno solare** (1 gennaio – 31 dicembre). Il plugin traccia il pagamento della quota per ogni anno nella tabella `ton_reg_membership_renewals`. + +**Gestione admin:** + +- **Elenco iscrizioni:** colonna **Rinnovo** con stato quota anno corrente (Pagato / Da pagare / Sollecito / Non pagato). +- Filtro **Quota da pagare** (ammessi senza quota pagata per l'anno in corso). +- **Dettaglio iscrizione:** sezione **Quote associative annuali** con checkbox «Pagata» per ogni anno (da anno iscrizione all'anno in corso; l'anno successivo compare automaticamente a gennaio). + +**Promemoria automatici (WP-Cron giornaliero):** + +| Data | Destinatari | Contenuto | +|------|-------------|-----------| +| **1 gennaio** | Soci ammessi senza quota pagata | Primo promemoria con istruzioni bonifico | +| **25 gennaio** | Stessi soci ancora senza pagamento | Secondo sollecito | +| Stesse date | Email admin (`ton_reg_admin_email`) | Riepilogo elenco soci da pagare | + +Testi email configurabili in **Impostazioni** (oggetto/corpo primo e secondo promemoria, riepilogo admin). + +**Placeholder email rinnovo:** `{nome}`, `{cognome}`, `{email}`, `{anno}`, `{quota}`, `{beneficiario}`, `{iban}`, `{payment_instructions}`, `{profile_url}`, `{gdpr_contact}`. Admin rinnovi: `{lista_soci}`, `{numero_soci}`, `{anno}`, `{admin_url}`. + +**Soci già ammessi prima dell'aggiornamento:** nessun backfill automatico; risultano «da pagare» finché l'admin non spunta la checkbox o fino ai promemoria di gennaio. + +**Anno inizio promemoria** (`ton_reg_renewal_reminders_start_year`, default **2027**): nessuna email automatica parte all'installazione/aggiornamento del plugin; i primi invii avvengono solo dal **1 gennaio** dell'anno configurato (poi sollecito il 25 gennaio). Modificabile in **Impostazioni → Anno inizio promemoria rinnovo**. + +**Affidabilità cron:** WP-Cron richiede visite al sito; per invii puntuali a inizio gennaio configurare un cron di sistema che chiami `wp-cron.php`. + +--- + +## 9. GDPR + +### Raccolta + +- Informativa visibile prima dei checkbox privacy. +- Consensi espliciti con timestamp. +- IP registrato (base giuridica documentata nelle impostazioni editabili). + +### Diritti (azioni admin) + +| Azione | Effetto | +|--------|---------| +| **Esporta JSON/CSV** | Dump dati iscrizione + meta utente WP | +| **Anonimizza** | PII sostituiti con `ANONIMIZZATO`; mantiene ID e date consenso; utente WP con email fittizia | +| **Elimina** | `wp_delete_user()` + cancellazione riga tabella + documenti | + +Contatto predefinito interessato: `qeshet.tonitalia@gmail.com` (modificabile). + +### Privacy by design + +- Nessun tracking terze parti nel form. +- Minimizzazione campi rispetto al PDF modulo. + +--- + +## 10. Disinstallazione + +**Disattivazione:** non cancella dati. + +**Eliminazione plugin** (da Plugin → Elimina): + +- Drop tabella `ton_registrations` e `ton_reg_membership_renewals`. +- Rimozione opzioni `ton_reg_*` e transient `ton_reg_*`. +- Eliminazione cartella `wp-content/uploads/Documenti Iscritti/`. +- Gli utenti **Subscriber** creati dal plugin **restano** (default). + +Opzione **“Elimina utenti alla disinstallazione”** in Impostazioni: se attiva prima della rimozione, cancella anche gli utenti WP collegati alle iscrizioni. + +--- + +## 11. Struttura codice (sviluppatori) + +``` +ton-italia-registration.php Bootstrap +includes/ + class-plugin.php Orchestrazione, upgrade DB + class-registration-form.php Shortcode + HTML form + class-registration-handler.php Submit, validazione, save + class-captcha.php Anti-spam + class-user-manager.php Subscriber + login univoco + class-database.php CRUD tabella + class-mailer.php wp_mail + promemoria rinnovo + class-membership-renewals.php Quote annuali per anno + class-renewal-reminders.php Cron promemoria gennaio + class-membership-card.php Generazione tessera PDF (FPDI) + class-membership-cards.php CRUD tessere + class-members-export.php Export CSV Libro Soci + class-profile.php Profilo subscriber + download tessera + class-gdpr.php Export / anonimizza / erase + class-documents.php Upload Media per iscritto + class-defaults.php Testi default PDF +admin/ + class-admin.php Menu, settings, dettaglio + class-registration-list-table.php +assets/css, assets/js Form frontend +uninstall.php Hook disinstallazione +``` + +--- + +## 12. Upgrade versione plugin + +1. Backup DB e cartella `Documenti Iscritti`. +2. Sostituire cartella plugin (o `git pull`). +3. Visitare qualsiasi pagina admin — `maybe_upgrade()` esegue `dbDelta` se serve nuove colonne. +4. Verificare **TON Iscrizioni → Elenco** e una scheda dettaglio. + +--- + +## 13. Risoluzione problemi + +| Problema | Soluzione | +|----------|-----------| +| Form non appare | Pagina contiene shortcode? Plugin attivo? | +| Email non arrivano | Test SMTP; spam; `wp_mail` log | +| “Email già registrata” | Utente WP o iscrizione attiva esistente | +| Elenco vuoto ma conta 1 iscrizione | Aggiornare a v1.3+ (fix paginazione) | +| PHP compatibility warning | Hosting PHP ≥ 8.0 | +| Upload documento fallisce | Permessi `uploads/`, max upload PHP, formato PDF/JPG/PNG | +| Subscriber accede wp-admin | Plugin blocca redirect; verificare ruolo subscriber | + +--- + +## 14. Workflow operativo consigliato (segreteria) + +1. Richiesta arriva → email admin + stato **In attesa**. +2. Verificare dati e caricare **documento identità** / **ricevute** in dettaglio. +3. Verificare bonifico su conto corrente. +4. Consiglio Direttivo: impostare **Ammesso** o **Non ammesso** + data Libro Associati. +5. Segnare **Quota pagata** per l'anno di iscrizione nella scheda socio. +6. Nel dettaglio iscrizione usare **Socio precedente** / **Socio successivo** per scorrere l'elenco senza tornare indietro. +7. A gennaio: verificare colonna **Rinnovo** / filtro **Quota da pagare**; i promemoria email partono il 1 e il 25 gennaio. +8. Comunicazione esterna all'iscritto (fuori plugin o email manuale). +9. Su richiesta cancellazione dati: **Anonimizza** o **Elimina** da scheda GDPR. + +--- + +## 15. Repository Git + +```bash +cd wp-content/plugins/ton-italia-registration +git pull origin master +git add . +git commit -m "v1.x: descrizione" +git push origin master +``` + +Pacchetti zip release in `releases/ton-italia-registration-X.Y.Z.zip` (nel repository GitBucket, tag `vX.Y.Z`). + +**Download ultima release (1.8.2):** +`https://niphredil.duckdns.org/git/fabio/tonitalia-pluginsoci/raw/v1.8.2/releases/ton-italia-registration-1.8.2.zip` + +Per generare un nuovo zip dal progetto sito: `./deploy/build-plugin-zip.sh` nella root di tents-of-nations, poi copiare in `releases/` del repo plugin. + +**Importante — struttura zip:** il file zip deve contenere la cartella `ton-italia-registration/` come radice. WordPress deve **sostituire** la cartella esistente, non crearne una nuplicata. + +--- + +| Versione | Novità principali | +|----------|-------------------| +| 1.6 | Integrazione Mailchimp su consenso newsletter | +| 1.5 | Ricevuta pagamento (3° documento) | +| 1.4 | Sezione consensi in dettaglio admin | +| 1.3 | Fix elenco iscrizioni | +| 1.2 | Date form gg-mm-aaaa | +| 1.1 | Upload documenti identità/bonifico | +| 1.0 | Release iniziale | + +--- + +## 17. Contatti tecnici + +- Sviluppo: Fabio Arrigoni +- GDPR operativo TON: `qeshet.tonitalia@gmail.com` (configurabile in plugin) diff --git a/admin/class-admin.php b/admin/class-admin.php index df2bfd4..eeb4a48 100644 --- a/admin/class-admin.php +++ b/admin/class-admin.php @@ -27,6 +27,11 @@ add_action( 'admin_post_ton_reg_delete_document', array( __CLASS__, 'delete_document' ) ); add_action( 'admin_post_ton_reg_mailchimp_test', array( __CLASS__, 'mailchimp_test' ) ); add_action( 'admin_post_ton_reg_mailchimp_retry', array( __CLASS__, 'mailchimp_retry' ) ); + add_action( 'admin_post_ton_reg_save_card_settings', array( __CLASS__, 'save_card_settings' ) ); + add_action( 'admin_post_ton_reg_upload_card_template', array( __CLASS__, 'upload_card_template' ) ); + add_action( 'admin_post_ton_reg_batch_cards', array( __CLASS__, 'batch_cards' ) ); + add_action( 'admin_post_ton_reg_generate_card', array( __CLASS__, 'generate_card' ) ); + add_action( 'admin_post_ton_reg_export_libro_soci', array( __CLASS__, 'export_libro_soci' ) ); } /** @@ -95,6 +100,7 @@ $plain_text = array( 'ton_reg_social_year', + 'ton_reg_renewal_reminders_start_year', 'ton_reg_membership_fee', 'ton_reg_beneficiary', 'ton_reg_iban', @@ -102,6 +108,9 @@ 'ton_reg_gdpr_contact', 'ton_reg_email_admin_subject', 'ton_reg_email_user_subject', + 'ton_reg_email_renewal_first_subject', + 'ton_reg_email_renewal_second_subject', + 'ton_reg_email_renewal_admin_subject', 'ton_reg_rate_limit_max', 'ton_reg_rate_limit_window', 'ton_reg_privacy_version', @@ -112,6 +121,7 @@ 'ton_reg_mailchimp_api_key', 'ton_reg_mailchimp_list_id', 'ton_reg_mailchimp_permission_ids', + 'ton_reg_success_page_id', ); register_setting( @@ -136,6 +146,17 @@ ) ); + register_setting( + 'ton_reg_settings', + 'ton_reg_success_page_id', + array( + 'type' => 'integer', + 'sanitize_callback' => array( __CLASS__, 'sanitize_success_page_id' ), + 'show_in_rest' => false, + 'default' => 0, + ) + ); + $options = array_keys( TON_Reg_Defaults::options() ); foreach ( $options as $option ) { if ( in_array( $option, $skip_auto, true ) ) { @@ -180,6 +201,23 @@ } /** + * @param mixed $value Page ID. + * @return int + */ + public static function sanitize_success_page_id( $value ) { + $page_id = absint( $value ); + if ( ! $page_id ) { + return 0; + } + + if ( ! TON_Reg_Registration_Handler::is_publicly_viewable_page( $page_id ) ) { + return 0; + } + + return $page_id; + } + + /** * List page. */ public static function page_list() { @@ -196,30 +234,43 @@ $table->prepare_items(); $status = isset( $_GET['status'] ) ? sanitize_text_field( wp_unslash( $_GET['status'] ) ) : ''; + $renewal_unpaid = ! empty( $_GET['renewal_unpaid'] ); ?> -
+

-

' . esc_html__( 'Non trovato.', 'ton-italia-registration' ) . '

'; return; } + + $list_context = self::get_detail_list_context(); + $adjacent = TON_Reg_Database::get_adjacent_ids( $id, $list_context ); ?> -
+

-

+ @@ -255,12 +309,17 @@ + +
+ + + + */ + public static function get_detail_list_context() { + if ( ! empty( $_POST['ton_reg_list_context'] ) && is_array( $_POST['ton_reg_list_context'] ) ) { + $source = wp_unslash( $_POST['ton_reg_list_context'] ); + } else { + $source = wp_unslash( $_GET ); + } + + $orderby = sanitize_text_field( wp_unslash( $source['list_orderby'] ?? $source['orderby'] ?? 'created_at' ) ); + $order = sanitize_text_field( wp_unslash( $source['list_order'] ?? $source['order'] ?? 'DESC' ) ); + $allowed_orderby = array( 'id', 'created_at', 'cognome', 'nome', 'email', 'status' ); + + return array( + 'status' => sanitize_text_field( wp_unslash( $source['list_status'] ?? $source['status'] ?? '' ) ), + 'search' => sanitize_text_field( wp_unslash( $source['list_search'] ?? $source['s'] ?? '' ) ), + 'renewal_unpaid' => ! empty( $source['list_renewal_unpaid'] ?? $source['renewal_unpaid'] ?? '' ), + 'renewal_year' => TON_Reg_Membership_Renewals::current_year(), + 'orderby' => in_array( $orderby, $allowed_orderby, true ) ? $orderby : 'created_at', + 'order' => 'ASC' === strtoupper( $order ) ? 'ASC' : 'DESC', + ); + } + + /** + * @param int $id Registration ID. + * @param array $context List context. + * @return string + */ + public static function detail_url( $id, $context = array() ) { + $args = array( + 'page' => 'ton-registrations', + 'action' => 'view', + 'id' => (int) $id, + ); + + if ( ! empty( $context['status'] ) ) { + $args['list_status'] = $context['status']; + } + if ( ! empty( $context['search'] ) ) { + $args['list_search'] = $context['search']; + } + if ( ! empty( $context['renewal_unpaid'] ) ) { + $args['list_renewal_unpaid'] = '1'; + } + if ( ! empty( $context['orderby'] ) && 'created_at' !== $context['orderby'] ) { + $args['list_orderby'] = $context['orderby']; + } + if ( ! empty( $context['order'] ) && 'DESC' !== strtoupper( (string) $context['order'] ) ) { + $args['list_order'] = $context['order']; + } + + return add_query_arg( $args, admin_url( 'admin.php' ) ); + } + + /** + * @param array $context List context. + * @return string + */ + public static function list_url( $context = array() ) { + $args = array( + 'page' => 'ton-registrations', + ); + + if ( ! empty( $context['status'] ) ) { + $args['status'] = $context['status']; + } + if ( ! empty( $context['search'] ) ) { + $args['s'] = $context['search']; + } + if ( ! empty( $context['renewal_unpaid'] ) ) { + $args['renewal_unpaid'] = '1'; + } + if ( ! empty( $context['orderby'] ) && 'created_at' !== $context['orderby'] ) { + $args['orderby'] = $context['orderby']; + } + if ( ! empty( $context['order'] ) && 'DESC' !== strtoupper( (string) $context['order'] ) ) { + $args['order'] = $context['order']; + } + + return add_query_arg( $args, admin_url( 'admin.php' ) ); + } + + /** + * @param int $id Registration ID. + * @param object $row Registration row. + * @param array{prev:int|null,next:int|null} $adjacent Adjacent IDs. + * @param array $context List context. + */ + private static function render_detail_navigation( $id, $row, $adjacent, $context ) { + $name = trim( ( $row->cognome ?? '' ) . ' ' . ( $row->nome ?? '' ) ); + ?> +
+

+ +

+
+ + + + + + + + + + + +
+
+ $context List context. + */ + private static function render_detail_list_context_fields( $context ) { + $fields = array( + 'list_status' => $context['status'] ?? '', + 'list_search' => $context['search'] ?? '', + 'list_renewal_unpaid' => ! empty( $context['renewal_unpaid'] ) ? '1' : '', + 'list_orderby' => $context['orderby'] ?? 'created_at', + 'list_order' => $context['order'] ?? 'DESC', + ); + + foreach ( $fields as $key => $value ) { + if ( '' === (string) $value ) { + continue; + } + printf( + '', + esc_attr( $key ), + esc_attr( (string) $value ) + ); + } + } + + /** + * @param int $id Registration ID. + * @param object $row Registration row. + */ + private static function render_renewals_section( $id, $row ) { + if ( 'admitted' !== ( $row->status ?? '' ) || ! empty( $row->anonymized_at ) ) { + return; + } + + $year_range = TON_Reg_Membership_Renewals::get_checkbox_year_range( $row ); + $records = TON_Reg_Membership_Renewals::get_by_registration( $id ); + $paid_map = array(); + + foreach ( $records as $record ) { + if ( TON_Reg_Membership_Renewals::STATUS_PAID === (string) $record->status ) { + $paid_map[ (int) $record->year ] = true; + } + } + ?> +

+

+
+ + + + + + +
+ + + +
+

' . esc_html( $msg ) . '

'; } + if ( isset( $_GET['card_generated'] ) ) { + echo '

' . esc_html__( 'Tessera generata.', 'ton-italia-registration' ) . '

'; + } + if ( ! empty( $_GET['card_error'] ) ) { + $msg = sanitize_text_field( wp_unslash( rawurldecode( $_GET['card_error'] ) ) ); + echo '

' . esc_html( $msg ) . '

'; + } } /** @@ -593,6 +863,42 @@

+ +

+ + +

+ + +

+ + +

+ + +
+

+

+ +

+

+ +

+

+ +

+

+ +

+
+ +
+

+

+ +

+

[ton_registration_success]

+

@@ -610,18 +916,49 @@ __( 'Messaggio successo', 'ton-italia-registration' ), 'ton_reg_payment_instructions' => __( 'Istruzioni bonifico', 'ton-italia-registration' ), 'ton_reg_statuto_declaration' => __( 'Dichiarazione statuto (checkbox 1)', 'ton-italia-registration' ), 'ton_reg_privacy_notice' => __( 'Informativa privacy', 'ton-italia-registration' ), 'ton_reg_newsletter_label' => __( 'Label consenso newsletter', 'ton-italia-registration' ), 'ton_reg_minor_notice' => __( 'Avviso minorenni', 'ton-italia-registration' ), - 'ton_reg_success_message' => __( 'Messaggio successo', 'ton-italia-registration' ), 'ton_reg_email_admin_body' => __( 'Email admin (testo)', 'ton-italia-registration' ), 'ton_reg_email_user_body' => __( 'Email utente (testo)', 'ton-italia-registration' ), + 'ton_reg_email_renewal_first_body' => __( 'Email rinnovo — primo promemoria (1 gen)', 'ton-italia-registration' ), + 'ton_reg_email_renewal_second_body' => __( 'Email rinnovo — secondo promemoria (25 gen)', 'ton-italia-registration' ), + 'ton_reg_email_renewal_admin_body' => __( 'Email admin rinnovi (riepilogo)', 'ton-italia-registration' ), + ); + $textarea_hints = array( + 'ton_reg_success_message' => __( 'Testo mostrato dallo shortcode [ton_registration_success] dopo l\'invio. HTML consentito (es. <p>...</p>).', 'ton-italia-registration' ), ); ?> + + + + + + + + @@ -668,13 +1005,23 @@ $label ) : ?> - + + + +
+ 'ton_reg_success_page_id', + 'id' => 'ton_reg_success_page_id', + 'selected' => (int) get_option( 'ton_reg_success_page_id', 0 ), + 'show_option_none' => __( '— Stessa pagina del form (fallback) —', 'ton-italia-registration' ), + 'option_none_value' => '0', + 'post_status' => array( 'publish' ), + ) + ); + ?> +

+
+ +

+
IBAN
+ + +

+ +
-

+

+

+

@@ -699,6 +1046,8 @@ + +
status; + $new_status = sanitize_text_field( wp_unslash( $_POST['status'] ?? 'pending' ) ); + TON_Reg_Database::update( $id, array( - 'status' => sanitize_text_field( wp_unslash( $_POST['status'] ?? 'pending' ) ), + 'status' => $new_status, 'libro_associati_date' => sanitize_text_field( wp_unslash( $_POST['libro_associati_date'] ?? '' ) ) ?: null, 'admin_notes' => sanitize_textarea_field( wp_unslash( $_POST['admin_notes'] ?? '' ) ), ) ); - wp_safe_redirect( admin_url( 'admin.php?page=ton-registrations&action=view&id=' . $id . '&updated=1' ) ); + $redirect_args = array( 'updated' => '1' ); + $card_result = TON_Reg_Membership_Card::maybe_generate_on_admission( $id, $old_status, $new_status ); + if ( is_wp_error( $card_result ) ) { + $redirect_args['card_error'] = rawurlencode( $card_result->get_error_message() ); + } elseif ( null !== $card_result ) { + $redirect_args['card_generated'] = '1'; + } + + self::save_renewal_checkboxes( $id, $row ); + + wp_safe_redirect( + add_query_arg( + $redirect_args, + self::detail_url( $id, self::get_detail_list_context() ) + ) + ); exit; } /** + * Export admitted members as Libro Soci CSV. + */ + public static function export_libro_soci() { + if ( ! current_user_can( 'manage_options' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'ton_reg_export_libro_soci' ) ) { + wp_die( esc_html__( 'Non autorizzato.', 'ton-italia-registration' ), 403 ); + } + + TON_Reg_Members_Export::stream_libro_soci_csv(); + } + + /** * GDPR export download. */ public static function gdpr_export() { @@ -904,4 +1287,417 @@ ); exit; } + + /** + * Membership cards section on registration detail. + * + * @param int $id Registration ID. + * @param object $row Registration row. + */ + private static function render_membership_cards_section( $id, $row ) { + $cards = TON_Reg_Membership_Cards::get_by_registration( $id ); + $active_year = TON_Reg_Membership_Card::get_active_year(); + ?> +

+ +

+ + + + + + + + + + + + + + + + + + + + +
year ); ?>created_at ) ); ?>generation_context ) ); ?> + + + +
+ + + status && empty( $row->anonymized_at ) ) : ?> +
+ + + + + + +
+ +

' . esc_html__( 'Libreria PDF (FPDI) non trovata in vendor/.', 'ton-italia-registration' ) . '

'; + return; + } + + $active_year = TON_Reg_Membership_Card::get_active_year(); + $config = TON_Reg_Membership_Card::get_template_config( $active_year ); + $template_id = (int) ( $config['template_attachment_id'] ?? 0 ); + $template_url = $template_id ? wp_get_attachment_url( $template_id ) : ''; + $admitted_count = TON_Reg_Database::count( + array( + 'status' => 'admitted', + 'exclude_anonymized' => true, + ) + ); + ?> +
+

+

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
/>
+ +
+ +

+
+ +

+ + +

+ + +
+ +
+ + + +

+

+

+ +
+ +
+ + + + +

+

+ +

+

+ +

+ +
+ (int) ( $current['template_attachment_id'] ?? 0 ), + 'name_x' => isset( $_POST['name_x'] ) ? (float) wp_unslash( $_POST['name_x'] ) : 42.5, + 'name_y' => isset( $_POST['name_y'] ) ? (float) wp_unslash( $_POST['name_y'] ) : 118.0, + 'font_family' => sanitize_text_field( wp_unslash( $_POST['font_family'] ?? 'Helvetica' ) ), + 'font_style' => sanitize_text_field( wp_unslash( $_POST['font_style'] ?? '' ) ), + 'font_size' => isset( $_POST['font_size'] ) ? (float) wp_unslash( $_POST['font_size'] ) : 14, + 'text_color' => sanitize_text_field( wp_unslash( $_POST['text_color'] ?? '#000000' ) ), + 'uppercase' => ! empty( $_POST['uppercase'] ), + 'align' => sanitize_text_field( wp_unslash( $_POST['align'] ?? 'C' ) ), + 'cell_width' => isset( $_POST['cell_width'] ) ? (float) wp_unslash( $_POST['cell_width'] ) : 80, + ) + ); + + wp_safe_redirect( admin_url( 'admin.php?page=ton-reg-settings&card_settings_saved=1' ) ); + exit; + } + + /** + * Upload card template PDF for active year. + */ + public static function upload_card_template() { + TON_Reg_Membership_Card::assert_can_manage_cards(); + check_admin_referer( 'ton_reg_upload_card_template', 'ton_reg_card_template_nonce' ); + + $year = isset( $_POST['active_year'] ) ? (int) $_POST['active_year'] : TON_Reg_Membership_Card::get_active_year(); + $file = isset( $_FILES['card_template'] ) ? $_FILES['card_template'] : null; + + $attachment_id = TON_Reg_Membership_Card::upload_template_pdf( $file, $year ); + + if ( is_wp_error( $attachment_id ) ) { + wp_safe_redirect( + add_query_arg( + 'card_error', + rawurlencode( $attachment_id->get_error_message() ), + admin_url( 'admin.php?page=ton-reg-settings' ) + ) + ); + exit; + } + + update_post_meta( $attachment_id, '_ton_reg_card_template_year', $year ); + + $current = TON_Reg_Membership_Card::get_template_config( $year ); + $old_id = (int) ( $current['template_attachment_id'] ?? 0 ); + if ( $old_id && $old_id !== (int) $attachment_id ) { + wp_delete_attachment( $old_id, true ); + } + + TON_Reg_Membership_Card::save_template_config( + $year, + array( + 'template_attachment_id' => (int) $attachment_id, + ) + ); + + wp_safe_redirect( admin_url( 'admin.php?page=ton-reg-settings&card_template_ok=1' ) ); + exit; + } + + /** + * Chunked batch card generation. + */ + public static function batch_cards() { + TON_Reg_Membership_Card::assert_can_manage_cards(); + check_admin_referer( 'ton_reg_batch_cards', 'ton_reg_batch_cards_nonce' ); + + $year = isset( $_POST['year'] ) ? (int) $_POST['year'] : TON_Reg_Membership_Card::get_active_year(); + $offset = isset( $_POST['offset'] ) ? (int) $_POST['offset'] : 0; + $force_regenerate = ! empty( $_POST['force_regenerate'] ); + $chunk_size = 20; + + $generated = isset( $_POST['batch_generated'] ) ? (int) $_POST['batch_generated'] : 0; + $skipped = isset( $_POST['batch_skipped'] ) ? (int) $_POST['batch_skipped'] : 0; + $errors = isset( $_POST['batch_errors'] ) ? (int) $_POST['batch_errors'] : 0; + + $rows = TON_Reg_Database::query( + array( + 'status' => 'admitted', + 'exclude_anonymized' => true, + 'limit' => $chunk_size, + 'offset' => $offset, + 'orderby' => 'id', + 'order' => 'ASC', + ) + ); + + foreach ( $rows as $row ) { + $existing = TON_Reg_Membership_Cards::get_by_registration_year( (int) $row->id, $year ); + if ( $existing && ! $force_regenerate ) { + ++$skipped; + continue; + } + + $result = TON_Reg_Membership_Card::generate_for_registration( + (int) $row->id, + $year, + array( + 'context' => TON_Reg_Membership_Card::CONTEXT_BATCH, + 'force_regenerate' => $force_regenerate, + ) + ); + + if ( is_wp_error( $result ) ) { + ++$errors; + } else { + ++$generated; + } + } + + $processed = count( $rows ); + if ( $processed < $chunk_size ) { + wp_safe_redirect( + add_query_arg( + array( + 'card_batch_done' => '1', + 'batch_generated' => $generated, + 'batch_skipped' => $skipped, + 'batch_errors' => $errors, + ), + admin_url( 'admin.php?page=ton-reg-settings' ) + ) + ); + exit; + } + + ?> + + <?php esc_html_e( 'Generazione tessere…', 'ton-italia-registration' ); ?> + +

+
+ + + + + + + + + + +
+ + TON_Reg_Membership_Card::CONTEXT_MANUAL, + 'force_regenerate' => ! empty( $_POST['force_regenerate'] ), + ) + ); + + $redirect = admin_url( 'admin.php?page=ton-registrations&action=view&id=' . $id ); + + if ( is_wp_error( $result ) ) { + wp_safe_redirect( add_query_arg( 'card_error', rawurlencode( $result->get_error_message() ), $redirect ) ); + exit; + } + + wp_safe_redirect( add_query_arg( 'card_generated', '1', $redirect ) ); + exit; + } } diff --git a/admin/class-registration-list-table.php b/admin/class-registration-list-table.php index cebd5df..b007578 100644 --- a/admin/class-registration-list-table.php +++ b/admin/class-registration-list-table.php @@ -17,6 +17,41 @@ class TON_Reg_Registration_List_Table extends WP_List_Table { /** + * @var array + */ + private $renewal_statuses = array(); + + /** + * @var int + */ + private $renewal_year = 0; + + /** + * @var string + */ + private $list_status = ''; + + /** + * @var string + */ + private $list_search = ''; + + /** + * @var bool + */ + private $list_renewal_unpaid = false; + + /** + * @var string + */ + private $list_orderby = 'created_at'; + + /** + * @var string + */ + private $list_order = 'DESC'; + + /** * Constructor. */ public function __construct() { @@ -39,6 +74,7 @@ 'nome' => __( 'Nome', 'ton-italia-registration' ), 'email' => __( 'Email', 'ton-italia-registration' ), 'status' => __( 'Stato', 'ton-italia-registration' ), + 'renewal' => __( 'Rinnovo', 'ton-italia-registration' ), 'created_at' => __( 'Data', 'ton-italia-registration' ), ); } @@ -48,11 +84,74 @@ * @return string */ public function column_id( $item ) { - $view_url = admin_url( 'admin.php?page=ton-registrations&action=view&id=' . (int) $item->id ); + return (string) (int) $item->id; + } + + /** + * @param object $item Item. + * @return string + */ + public function column_cognome( $item ) { + $view_url = $this->build_detail_url( (int) $item->id ); + $name = trim( ( $item->cognome ?? '' ) . ' ' . ( $item->nome ?? '' ) ); $actions = array( 'view' => '' . esc_html__( 'Dettaglio', 'ton-italia-registration' ) . '', ); - return (int) $item->id . $this->row_actions( $actions ); + + return '' . esc_html( $name ) . '' . $this->row_actions( $actions ); + } + + /** + * @param int $id Registration ID. + * @return string + */ + private function build_detail_url( $id ) { + return TON_Reg_Admin::detail_url( $id, $this->get_list_context() ); + } + + /** + * @return array + */ + public function get_list_context() { + return array( + 'status' => $this->list_status, + 'search' => $this->list_search, + 'renewal_unpaid' => $this->list_renewal_unpaid, + 'renewal_year' => $this->renewal_year, + 'orderby' => $this->list_orderby, + 'order' => $this->list_order, + ); + } + + /** + * @param object $item Item. + * @return string + */ + public function column_email( $item ) { + $email = $item->email ?? ''; + if ( ! $email ) { + return ''; + } + + return '' . esc_html( $email ) . ''; + } + + /** + * @param object $item Item. + * @return string + */ + public function column_created_at( $item ) { + $value = $item->created_at ?? ''; + if ( ! $value ) { + return ''; + } + + $timestamp = strtotime( $value ); + if ( ! $timestamp ) { + return esc_html( $value ); + } + + return esc_html( wp_date( 'd/m/Y H:i', $timestamp ) ); } /** @@ -65,11 +164,32 @@ 'admitted' => __( 'Ammesso', 'ton-italia-registration' ), 'rejected' => __( 'Non ammesso', 'ton-italia-registration' ), ); - $label = $labels[ $item->status ] ?? $item->status; + $status = $item->status ?? ''; + $label = $labels[ $status ] ?? $status; + $class = 'ton-reg-status ton-reg-status--' . sanitize_html_class( $status ); + if ( $item->anonymized_at ) { $label .= ' (' . __( 'anonimizzato', 'ton-italia-registration' ) . ')'; + $class .= ' ton-reg-status--anonymized'; } - return esc_html( $label ); + + return '' . esc_html( $label ) . ''; + } + + /** + * @param object $item Item. + * @return string + */ + public function column_renewal( $item ) { + if ( 'admitted' !== ( $item->status ?? '' ) || ! empty( $item->anonymized_at ) ) { + return ''; + } + + $id = (int) $item->id; + $status = $this->renewal_statuses[ $id ] ?? TON_Reg_Membership_Renewals::STATUS_PENDING; + $badge = TON_Reg_Membership_Renewals::display_badge( $status, $this->renewal_year ); + + return '' . esc_html( $badge['label'] ) . ''; } /** @@ -103,29 +223,53 @@ * Prepare items. */ public function prepare_items() { - $per_page = 20; - $status = isset( $_REQUEST['status'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['status'] ) ) : ''; - $search = isset( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : ''; + $per_page = 20; + $status = isset( $_REQUEST['status'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['status'] ) ) : ''; + $search = isset( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : ''; + $renewal_unpaid = ! empty( $_REQUEST['renewal_unpaid'] ); + $this->renewal_year = TON_Reg_Membership_Renewals::current_year(); + $this->list_status = $status; + $this->list_search = $search; + $this->list_renewal_unpaid = $renewal_unpaid; - $total = TON_Reg_Database::count( - array( - 'status' => $status, - 'search' => $search, - ) + $orderby = isset( $_REQUEST['orderby'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['orderby'] ) ) : 'created_at'; + $order = isset( $_REQUEST['order'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) : 'DESC'; + $allowed_orderby = array( 'id', 'created_at', 'cognome', 'nome', 'email', 'status' ); + $this->list_orderby = in_array( $orderby, $allowed_orderby, true ) ? $orderby : 'created_at'; + $this->list_order = 'ASC' === strtoupper( $order ) ? 'ASC' : 'DESC'; + + $query_args = array( + 'status' => $status, + 'search' => $search, + 'renewal_unpaid' => $renewal_unpaid, + 'renewal_year' => $this->renewal_year, + 'orderby' => $this->list_orderby, + 'order' => $this->list_order, ); + $total = TON_Reg_Database::count( $query_args ); + $total_pages = max( 1, (int) ceil( $total / $per_page ) ); $page = max( 1, min( $this->get_pagenum(), $total_pages ) ); $this->items = TON_Reg_Database::query( - array( - 'status' => $status, - 'search' => $search, - 'limit' => $per_page, - 'offset' => ( $page - 1 ) * $per_page, + array_merge( + $query_args, + array( + 'limit' => $per_page, + 'offset' => ( $page - 1 ) * $per_page, + ) ) ); + $ids = array_map( + static function ( $item ) { + return (int) $item->id; + }, + $this->items + ); + $this->renewal_statuses = TON_Reg_Membership_Renewals::get_statuses_for_registrations( $ids, $this->renewal_year ); + $this->set_pagination_args( array( 'total_items' => $total, diff --git a/assets/css/admin.css b/assets/css/admin.css index c81a577..fc7bcd3 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -88,3 +88,390 @@ color: #646970; font-weight: 400; } + +/* —— Elenco iscrizioni (admin) —— */ + +.ton-reg-status { + display: inline-block; + font-size: 12px; + font-weight: 600; + line-height: 1.3; + padding: 0.2rem 0.55rem; + border-radius: 999px; + white-space: nowrap; +} + +.ton-reg-status--pending { + background: #fff3cd; + color: #664d03; +} + +.ton-reg-status--admitted { + background: #d8f3dc; + color: #1b4332; +} + +.ton-reg-status--rejected { + background: #ffe5e5; + color: #9b2226; +} + +.ton-reg-status--anonymized { + background: #f0f0f1; + color: #50575e; +} + +.ton-reg-admin-list .ton-reg-admin-list__filters { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.75rem; + float: none; + margin: 0.75rem 0 1rem; +} + +.ton-reg-admin-list .ton-reg-admin-list__filters li { + display: inline; + margin: 0; +} + +.ton-reg-admin-list .ton-reg-list-search { + clear: both; + margin: 0 0 1rem; +} + +.ton-reg-admin-list .ton-reg-admin-list__search { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + float: none; + margin: 0; + width: 100%; +} + +.ton-reg-admin-list .ton-reg-admin-list__search input[type="search"] { + flex: 1 1 12rem; + margin: 0; + max-width: none; + width: auto; +} + +.ton-reg-list-table .wp-list-table .column-cognome .row-title { + font-size: 14px; +} + +.ton-reg-list-table .tablenav { + margin-top: 0.75rem; +} + +@media (max-width: 782px) { + .ton-reg-admin-list h1 { + font-size: 1.4rem; + } + + .ton-reg-admin-list .ton-reg-admin-list__search { + align-items: stretch; + } + + .ton-reg-admin-list .ton-reg-admin-list__search input[type="search"], + .ton-reg-admin-list .ton-reg-admin-list__search .button { + width: 100%; + } + + .ton-reg-list-table { + margin: 0 -10px; + } + + .ton-reg-list-table .wp-list-table { + border: 0; + } + + .ton-reg-list-table .wp-list-table thead, + .ton-reg-list-table .wp-list-table tfoot { + display: none; + } + + .ton-reg-list-table .wp-list-table tbody { + display: block; + } + + .ton-reg-list-table .wp-list-table tbody tr { + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 6px; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); + display: flex; + flex-direction: column; + margin: 0 0 0.75rem; + padding: 0.35rem 0.75rem 0.75rem; + } + + .ton-reg-list-table .wp-list-table tbody tr td { + border: 0; + display: flex; + align-items: flex-start; + gap: 0.75rem; + justify-content: space-between; + padding: 0.45rem 0; + text-align: right; + } + + .ton-reg-list-table .wp-list-table tbody tr td::before { + color: #646970; + content: attr(data-colname); + flex: 0 0 5.5rem; + font-size: 12px; + font-weight: 600; + line-height: 1.4; + text-align: left; + text-transform: none; + } + + .ton-reg-list-table .wp-list-table tbody tr td.column-cognome { + align-items: flex-start; + border-bottom: 1px solid #dcdcde; + display: flex; + flex-direction: column; + gap: 0.5rem; + justify-content: flex-start; + margin-bottom: 0.35rem; + order: 1; + padding: 0.35rem 0 0.65rem; + text-align: left; + } + + .ton-reg-list-table .wp-list-table tbody tr td.column-cognome::before { + display: none; + } + + .ton-reg-list-table .wp-list-table tbody tr td.column-cognome .row-title { + display: block; + font-size: 1rem; + line-height: 1.35; + text-align: left; + width: 100%; + } + + .ton-reg-list-table .wp-list-table tbody tr td.column-cognome .row-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + justify-content: flex-start; + margin: 0; + padding: 0; + position: static; + visibility: visible; + width: 100%; + } + + .ton-reg-list-table .wp-list-table .column-nome { + display: none; + } + + .ton-reg-list-table .wp-list-table .column-id { + font-size: 12px; + order: 5; + } + + .ton-reg-list-table .wp-list-table .column-status { + order: 2; + } + + .ton-reg-list-table .wp-list-table .column-renewal { + order: 3; + } + + .ton-reg-list-table .wp-list-table .column-email { + order: 4; + word-break: break-word; + } + + .ton-reg-list-table .wp-list-table .column-created_at { + order: 5; + } + + .ton-reg-list-table .wp-list-table .row-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.65rem; + } + + .ton-reg-list-table .wp-list-table tbody tr td.column-cognome .row-actions { + margin-top: 0; + } + + .ton-reg-list-table .wp-list-table .row-actions .view a { + background: #2271b1; + border-radius: 4px; + color: #fff; + display: inline-block; + font-weight: 600; + padding: 0.45rem 0.85rem; + text-decoration: none; + } + + .ton-reg-list-table .wp-list-table .row-actions .view a:hover, + .ton-reg-list-table .wp-list-table .row-actions .view a:focus { + background: #135e96; + color: #fff; + } + + .ton-reg-list-table .tablenav .tablenav-pages { + float: none; + margin: 0.5rem 0 0; + text-align: center; + width: 100%; + } + + .ton-reg-list-table .tablenav .displaying-num { + display: block; + float: none; + margin: 0 0 0.5rem; + text-align: center; + width: 100%; + } + + .ton-reg-admin-detail .form-table, + .ton-reg-admin-detail .form-table tbody, + .ton-reg-admin-detail .form-table tr, + .ton-reg-admin-detail .form-table th, + .ton-reg-admin-detail .form-table td { + display: block; + width: 100%; + } + + .ton-reg-admin-detail .form-table th { + border-bottom: 0; + padding: 0.75rem 0 0.25rem; + } + + .ton-reg-admin-detail .form-table td { + margin: 0 0 0.75rem; + padding: 0 0 0.75rem; + } + + .ton-reg-admin-detail .form-table tr { + border-bottom: 1px solid #dcdcde; + margin-bottom: 0.25rem; + } + + .ton-reg-admin-detail .ton-reg-consents th { + width: auto; + } + + .ton-reg-admin-detail .ton-reg-doc-block__upload { + align-items: stretch; + flex-direction: column; + } + + .ton-reg-admin-detail .ton-reg-doc-block__upload .button { + width: 100%; + } + + .ton-reg-detail-nav__pager { + align-items: stretch; + flex-direction: column; + } + + .ton-reg-detail-nav__pager .button, + .ton-reg-detail-nav__pager .button.disabled { + text-align: center; + width: 100%; + } + + .ton-reg-detail-nav__current { + order: -1; + text-align: left; + } +} + +.ton-reg-admin-list__export { + margin: 0.75rem 0 1rem; +} + +.ton-reg-admin-list__export .description { + margin-left: 0.5rem; +} + +.ton-reg-detail-nav { + margin: 0 0 1.25rem; +} + +.ton-reg-detail-nav__back { + margin: 0 0 0.75rem; +} + +.ton-reg-detail-nav__pager { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; +} + +.ton-reg-detail-nav__current { + color: #1d2327; + flex: 1 1 12rem; + font-weight: 600; + min-width: 0; + text-align: center; +} + +.ton-reg-detail-nav__pager .button.disabled { + color: #a7aaad; + cursor: default; + opacity: 0.65; + pointer-events: none; +} + +.ton-reg-renewal { + display: inline-block; + font-size: 12px; + font-weight: 600; + line-height: 1.3; + padding: 0.2rem 0.55rem; + border-radius: 999px; + white-space: nowrap; +} + +.ton-reg-renewal--paid { + background: #d8f3dc; + color: #1b4332; +} + +.ton-reg-renewal--pending { + background: #fff3cd; + color: #664d03; +} + +.ton-reg-renewal--reminder { + background: #ffe8cc; + color: #9a3412; +} + +.ton-reg-renewal--overdue { + background: #ffe5e5; + color: #9b2226; +} + +.ton-reg-renewal--na { + background: transparent; + color: #a7aaad; + font-weight: 400; + padding: 0; +} + +.ton-reg-profile--admin th { + width: 220px; +} + +.ton-reg-profile--admin .ton-reg-profile__notice, +.ton-reg-profile--admin__notice { + margin: 0; + padding: 0.75rem 1rem; + background: #f0f6fc; + border-left: 4px solid #72aee6; +} + +.ton-reg-profile--admin .ton-reg-profile__download { + font-weight: 600; +} diff --git a/assets/css/form.css b/assets/css/form.css index 4c7954a..10ef2d7 100644 --- a/assets/css/form.css +++ b/assets/css/form.css @@ -5,6 +5,26 @@ line-height: 1.5; } +.ton-reg-form__body { + margin: 0; + width: 100%; +} + +.ton-reg-form__alert { + padding: 1rem 1.25rem; + border-radius: 4px; + margin-bottom: 1rem; +} + +.ton-reg-form__alert--error { + background: #ffe5e5; + border: 1px solid #f4a4a4; +} + +.ton-reg-form__alert--error p { + margin: 0; +} + .ton-reg-form__intro { font-weight: 600; margin-bottom: 1.5rem; @@ -30,6 +50,7 @@ .ton-reg-form__grid--narrow { max-width: 24rem; + grid-template-columns: 1fr; } @media (max-width: 600px) { @@ -40,6 +61,7 @@ .ton-reg-form__field { margin: 0; + min-width: 0; } .ton-reg-form__field label { @@ -149,20 +171,25 @@ background: #1b4332; } -.ton-reg-form--success, -.ton-reg-form--error { +.ton-reg-form--success { + background: #d8f3dc; + border: 1px solid #95d5b2; + color: #1b4332; padding: 1rem 1.25rem; border-radius: 4px; } -.ton-reg-form--success { - background: #d8f3dc; - border: 1px solid #95d5b2; +.ton-reg-form--success p { + margin: 0; + font-size: 1.05rem; + line-height: 1.5; } .ton-reg-form--error { background: #ffe5e5; border: 1px solid #f4a4a4; + padding: 1rem 1.25rem; + border-radius: 4px; } .ton-reg-form .required { @@ -176,3 +203,65 @@ border-radius: 4px; margin-bottom: 1rem; } + +.ton-reg-profile { + max-width: 640px; + margin: 0 auto; +} + +.ton-reg-profile__title { + margin-bottom: 1rem; +} + +.ton-reg-profile__summary { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.35rem 1.25rem; + margin: 0 0 1.5rem; +} + +.ton-reg-profile__summary dt { + font-weight: 600; + margin: 0; +} + +.ton-reg-profile__summary dd { + margin: 0; +} + +.ton-reg-profile__notice { + padding: 0.75rem 1rem; + background: #f0f4f8; + border-radius: 4px; +} + +.ton-reg-profile__card { + padding: 1rem 1.25rem; + background: #edf7f0; + border: 1px solid #b7dfc4; + border-radius: 4px; + margin-bottom: 1.5rem; +} + +.ton-reg-profile__card h3 { + margin: 0 0 0.75rem; +} + +.ton-reg-profile__history { + list-style: none; + margin: 0; + padding: 0; +} + +.ton-reg-profile__history li { + padding: 0.35rem 0; + border-bottom: 1px solid #e2e8f0; +} + +.ton-reg-profile__download { + font-weight: 600; +} + +.ton-reg-profile__password { + margin-top: 1.5rem; +} diff --git a/assets/js/form.js b/assets/js/form.js index f99dff7..f1a2544 100644 --- a/assets/js/form.js +++ b/assets/js/form.js @@ -1,168 +1,329 @@ (function () { 'use strict'; - var form = document.querySelector('.ton-reg-form form'); - if (!form || typeof tonRegForm === 'undefined') { - return; - } - - var cfRegex = /^[A-Z]{6}[0-9]{2}[A-Z][0-9]{2}[A-Z][0-9]{3}[A-Z]$/; - var dateRegex = /^[0-9]{2}-[0-9]{2}-[0-9]{4}$/; - - function showFieldError(fieldWrap, message) { - var input = fieldWrap.querySelector('.ton-reg-form__input'); - var error = fieldWrap.querySelector('.ton-reg-form__error'); - if (!input || !error) { + function initTonRegForm() { + if (window.tonRegFormBootstrapped) { return; } - fieldWrap.classList.add('ton-reg-form__field--invalid'); - error.textContent = message; - error.hidden = false; - input.setCustomValidity(message); - } - function clearFieldError(fieldWrap) { - var input = fieldWrap.querySelector('.ton-reg-form__input'); - var error = fieldWrap.querySelector('.ton-reg-form__error'); - if (!input || !error) { + var form = document.querySelector('.ton-reg-form__body'); + if (!form || form.getAttribute('data-ton-reg-js-init') === '1') { return; } - fieldWrap.classList.remove('ton-reg-form__field--invalid'); - error.hidden = true; - error.textContent = ''; - input.setCustomValidity(''); - } - function clearAllErrors() { - form.querySelectorAll('.ton-reg-form__field').forEach(clearFieldError); - form.querySelectorAll('.ton-reg-form__checkbox--invalid').forEach(function (el) { - el.classList.remove('ton-reg-form__checkbox--invalid'); - }); - } + form.setAttribute('data-ton-reg-js-init', '1'); + window.tonRegFormBootstrapped = true; - function getFieldWrap(name) { - return form.querySelector('.ton-reg-form__field[data-field="' + name + '"]'); - } + var messages = + typeof tonRegForm !== 'undefined' + ? tonRegForm + : { + msgRequired: 'Compila questo campo.', + msgEmail: 'Inserisci un indirizzo email valido.', + msgCf: 'Codice fiscale non valido (16 caratteri).', + msgCap: 'Il CAP deve essere di 5 cifre.', + msgProvincia: 'Inserisci la sigla provincia (2 lettere).', + msgDate: 'Inserisci la data nel formato gg-mm-aaaa.', + msgDateFuture: 'La data di dichiarazione non può essere futura.', + msgCheckbox: 'Devi selezionare questa casella per continuare.', + msgCaptcha: 'Inserisci la risposta alla verifica anti-spam.', + }; - function validateField(name) { - var wrap = getFieldWrap(name); - if (!wrap) { - return true; - } - var input = wrap.querySelector('.ton-reg-form__input'); - if (!input) { - return true; + var cfRegex = /^[A-Z]{6}[0-9]{2}[A-Z][0-9]{2}[A-Z][0-9]{3}[A-Z]$/; + var dateRegex = /^[0-9]{2}-[0-9]{2}-[0-9]{4}$/; + var dateFieldNames = { + data_nascita: true, + data_dichiarazione: true, + }; + + function isDateField(name) { + return !!dateFieldNames[name]; } - var value = (input.value || '').trim(); - - if (input.required && !value) { - showFieldError(wrap, tonRegForm.msgRequired); - return false; + function isDateInput(input) { + return input && input.name && isDateField(input.name); } - if (!value) { + function padDatePart(part) { + var num = parseInt(part, 10); + if (part.length === 1 && num >= 1 && num <= 9) { + return '0' + part; + } + return part; + } + + function normalizeItalianDate(value) { + var trimmed = (value || '').trim(); + if (!trimmed) { + return ''; + } + + var normalized = trimmed + .replace(/[./]/g, '-') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + var match = normalized.match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/); + if (!match) { + return normalized; + } + + return padDatePart(match[1]) + '-' + padDatePart(match[2]) + '-' + match[3]; + } + + function applyDateNormalization(input) { + if (!isDateInput(input)) { + return; + } + + var previous = input.value; + var normalized = normalizeItalianDate(previous); + input.value = normalized; + + if (normalized !== previous) { + input.dispatchEvent(new Event('change', { bubbles: true })); + } + } + + function normalizeProvince(value) { + return (value || '').trim().toUpperCase(); + } + + function normalizeCodiceFiscale(value) { + return (value || '').trim().toUpperCase().replace(/\s+/g, ''); + } + + function parseItalianDate(value) { + if (!dateRegex.test(value)) { + return null; + } + var parts = value.split('-'); + return new Date(parseInt(parts[2], 10), parseInt(parts[1], 10) - 1, parseInt(parts[0], 10)); + } + + function isFutureDate(value) { + var date = parseItalianDate(value); + if (!date) { + return false; + } + var today = new Date(); + today.setHours(0, 0, 0, 0); + date.setHours(0, 0, 0, 0); + return date > today; + } + + function showFieldError(fieldWrap, message) { + var input = fieldWrap.querySelector('.ton-reg-form__input'); + var error = fieldWrap.querySelector('.ton-reg-form__error'); + if (!input || !error) { + return; + } + fieldWrap.classList.add('ton-reg-form__field--invalid'); + error.textContent = message; + error.hidden = false; + input.setCustomValidity(message); + } + + function clearFieldError(fieldWrap) { + var input = fieldWrap.querySelector('.ton-reg-form__input'); + var error = fieldWrap.querySelector('.ton-reg-form__error'); + if (!input || !error) { + return; + } + fieldWrap.classList.remove('ton-reg-form__field--invalid'); + error.hidden = true; + error.textContent = ''; + input.setCustomValidity(''); + } + + function clearAllErrors() { + form.querySelectorAll('.ton-reg-form__field').forEach(clearFieldError); + form.querySelectorAll('.ton-reg-form__checkbox--invalid').forEach(function (el) { + el.classList.remove('ton-reg-form__checkbox--invalid'); + }); + } + + function getFieldWrap(name) { + return form.querySelector('.ton-reg-form__field[data-field="' + name + '"]'); + } + + function validateField(name) { + var wrap = getFieldWrap(name); + if (!wrap) { + return true; + } + var input = wrap.querySelector('.ton-reg-form__input'); + if (!input) { + return true; + } + + var value = (input.value || '').trim(); + + if (isDateField(name)) { + value = normalizeItalianDate(value); + input.value = value; + } + + if ('provincia_nascita' === name || 'provincia_residenza' === name) { + value = normalizeProvince(value); + input.value = value; + } + + if ('codice_fiscale' === name) { + value = normalizeCodiceFiscale(value); + input.value = value; + } + + if (input.required && !value) { + showFieldError(wrap, messages.msgRequired); + return false; + } + + if (!value) { + clearFieldError(wrap); + return true; + } + + if ('email' === input.type && !input.checkValidity()) { + showFieldError(wrap, messages.msgEmail); + return false; + } + + if ('codice_fiscale' === name) { + if (!cfRegex.test(value)) { + showFieldError(wrap, messages.msgCf); + return false; + } + } + + if ('cap' === name && !/^[0-9]{5}$/.test(value)) { + showFieldError(wrap, messages.msgCap); + return false; + } + + if (('provincia_nascita' === name || 'provincia_residenza' === name) && !/^[A-Za-z]{2}$/.test(value)) { + showFieldError(wrap, messages.msgProvincia); + return false; + } + + if (isDateField(name) && !dateRegex.test(value)) { + showFieldError(wrap, messages.msgDate); + return false; + } + + if ('data_dichiarazione' === name && dateRegex.test(value) && isFutureDate(value)) { + showFieldError(wrap, messages.msgDateFuture); + return false; + } + + if (input.pattern && !isDateField(name) && !new RegExp('^(?:' + input.pattern + ')$').test(value)) { + showFieldError(wrap, messages.msgRequired); + return false; + } + clearFieldError(wrap); return true; } - if ('email' === input.type && !input.checkValidity()) { - showFieldError(wrap, tonRegForm.msgEmail); - return false; + function validateCheckboxes() { + var ok = true; + form.querySelectorAll('input[type="checkbox"][required]').forEach(function (cb) { + var label = cb.closest('.ton-reg-form__checkbox'); + if (!cb.checked) { + if (label) { + label.classList.add('ton-reg-form__checkbox--invalid'); + } + ok = false; + } else if (label) { + label.classList.remove('ton-reg-form__checkbox--invalid'); + } + }); + return ok; } - if ('codice_fiscale' === name) { - if (!cfRegex.test(value.toUpperCase())) { - showFieldError(wrap, tonRegForm.msgCf); + function validateCaptcha() { + var captcha = form.querySelector('#ton_reg_captcha_answer'); + if (!captcha) { + return true; + } + if (!captcha.value && captcha.required) { + captcha.setCustomValidity(messages.msgCaptcha); return false; } - } - - if ('cap' === name && !/^[0-9]{5}$/.test(value)) { - showFieldError(wrap, tonRegForm.msgCap); - return false; - } - - if (('provincia_nascita' === name || 'provincia_residenza' === name) && !/^[A-Za-z]{2}$/.test(value)) { - showFieldError(wrap, tonRegForm.msgProvincia); - return false; - } - - if (('data_nascita' === name || 'data_dichiarazione' === name) && !dateRegex.test(value)) { - showFieldError(wrap, tonRegForm.msgDate); - return false; - } - - if (input.pattern && !new RegExp('^(?:' + input.pattern + ')$').test(value)) { - showFieldError(wrap, tonRegForm.msgRequired); - return false; - } - - clearFieldError(wrap); - return true; - } - - function validateCheckboxes() { - var ok = true; - form.querySelectorAll('input[type="checkbox"][required]').forEach(function (cb) { - var label = cb.closest('.ton-reg-form__checkbox'); - if (!cb.checked) { - if (label) { - label.classList.add('ton-reg-form__checkbox--invalid'); - } - ok = false; - } else if (label) { - label.classList.remove('ton-reg-form__checkbox--invalid'); - } - }); - return ok; - } - - function validateCaptcha() { - var captcha = form.querySelector('#ton_reg_captcha_answer'); - if (!captcha) { + captcha.setCustomValidity(''); return true; } - if (!captcha.value && captcha.required) { - captcha.setCustomValidity(tonRegForm.msgCaptcha); - return false; - } - captcha.setCustomValidity(''); - return true; - } - form.querySelectorAll('.ton-reg-form__input').forEach(function (input) { - input.addEventListener('input', function () { + function handleInputNormalization(input) { var wrap = input.closest('.ton-reg-form__field'); if (wrap) { clearFieldError(wrap); } + if ('codice_fiscale' === input.name && input.value) { - input.value = input.value.toUpperCase().replace(/\s+/g, ''); + input.value = normalizeCodiceFiscale(input.value); } + + if (isDateInput(input) && input.value) { + input.value = input.value.replace(/[./]/g, '-'); + } + if (('provincia_nascita' === input.name || 'provincia_residenza' === input.name) && input.value) { - input.value = input.value.toUpperCase(); + input.value = normalizeProvince(input.value); + } + } + + function handleFieldNormalization(input) { + if (!input || !input.name) { + return; + } + + if (isDateInput(input)) { + applyDateNormalization(input); + } + + if ('provincia_nascita' === input.name || 'provincia_residenza' === input.name) { + input.value = normalizeProvince(input.value); + } + + if ('codice_fiscale' === input.name) { + input.value = normalizeCodiceFiscale(input.value); + } + + validateField(input.name); + } + + form.addEventListener('input', function (e) { + if (e.target && e.target.classList && e.target.classList.contains('ton-reg-form__input')) { + handleInputNormalization(e.target); } }); - input.addEventListener('blur', function () { - if (input.name) { - validateField(input.name); - } - }); - }); + form.addEventListener( + 'focusout', + function (e) { + if (e.target && e.target.classList && e.target.classList.contains('ton-reg-form__input')) { + handleFieldNormalization(e.target); + } + }, + true + ); - var birthInput = form.querySelector('#ton_reg_data_nascita'); - if (birthInput) { - birthInput.addEventListener('change', function () { + function updateMinorBirthWarning() { + var birthInput = form.querySelector('#ton_reg_data_nascita'); + if (!birthInput) { + return; + } + var warning = form.querySelector('.ton-reg-form__warning'); if (warning) { warning.remove(); } - var date = birthInput.value; + + var date = normalizeItalianDate(birthInput.value); if (!date || !dateRegex.test(date)) { return; } + var parts = date.split('-'); var birth = new Date(parseInt(parts[2], 10), parseInt(parts[1], 10) - 1, parseInt(parts[0], 10)); var today = new Date(); @@ -179,57 +340,79 @@ 'Se sei minorenne, la domanda deve essere firmata da entrambi i genitori o dal tutore legale.'; form.insertBefore(div, form.firstChild); } + } + + var birthInput = form.querySelector('#ton_reg_data_nascita'); + if (birthInput) { + birthInput.addEventListener('change', updateMinorBirthWarning); + } + + form.addEventListener('submit', function (e) { + clearAllErrors(); + + var fields = [ + 'cognome', + 'nome', + 'luogo_nascita', + 'provincia_nascita', + 'data_nascita', + 'codice_fiscale', + 'comune_residenza', + 'provincia_residenza', + 'indirizzo', + 'numero_civico', + 'cap', + 'telefono', + 'email', + 'luogo_dichiarazione', + 'data_dichiarazione', + ]; + + var valid = true; + fields.forEach(function (name) { + if (!validateField(name)) { + valid = false; + } + }); + + if (!validateCheckboxes()) { + valid = false; + } + + if (!validateCaptcha()) { + valid = false; + } + + if (!valid) { + e.preventDefault(); + var firstInvalid = form.querySelector( + '.ton-reg-form__field--invalid .ton-reg-form__input, .ton-reg-form__checkbox--invalid input, :invalid' + ); + if (firstInvalid && firstInvalid.focus) { + firstInvalid.focus(); + } + return; + } + + if (!form.checkValidity()) { + e.preventDefault(); + form.reportValidity(); + } }); } - form.addEventListener('submit', function (e) { - clearAllErrors(); + window.tonRegFormInit = initTonRegForm; - var fields = [ - 'cognome', - 'nome', - 'luogo_nascita', - 'provincia_nascita', - 'data_nascita', - 'codice_fiscale', - 'comune_residenza', - 'provincia_residenza', - 'indirizzo', - 'numero_civico', - 'cap', - 'telefono', - 'email', - 'luogo_dichiarazione', - 'data_dichiarazione', - ]; - - var valid = true; - fields.forEach(function (name) { - if (!validateField(name)) { - valid = false; - } - }); - - if (!validateCheckboxes()) { - valid = false; + if (/[?&]ton_reg=success(?:&|$)/.test(window.location.search)) { + var successEl = document.getElementById('ton-reg-success'); + if (successEl) { + successEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); } + } - if (!validateCaptcha()) { - valid = false; - } - - if (!valid) { - e.preventDefault(); - var firstInvalid = form.querySelector('.ton-reg-form__field--invalid .ton-reg-form__input, .ton-reg-form__checkbox--invalid input, :invalid'); - if (firstInvalid && firstInvalid.focus) { - firstInvalid.focus(); - } - return; - } - - if (!form.checkValidity()) { - e.preventDefault(); - form.reportValidity(); - } - }); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initTonRegForm); + } else { + initTonRegForm(); + } })(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1b51dc8 --- /dev/null +++ b/composer.json @@ -0,0 +1,11 @@ +{ + "name": "ton-italia/registration", + "description": "TON Italia Registration WordPress plugin dependencies", + "require": { + "php": ">=8.0", + "setasign/fpdi-fpdf": "^2.6" + }, + "config": { + "sort-packages": true + } +} diff --git a/includes/class-activator.php b/includes/class-activator.php index c10aee4..bdfb6a6 100644 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -17,7 +17,9 @@ */ public static function activate() { TON_Reg_Database::create_table(); + TON_Reg_Membership_Renewals::create_table(); TON_Reg_Documents::install_folders(); + TON_Reg_Renewal_Reminders::schedule_cron(); $defaults = TON_Reg_Defaults::options(); foreach ( $defaults as $key => $value ) { diff --git a/includes/class-autoloader.php b/includes/class-autoloader.php index 54c20fb..fe4ae55 100644 --- a/includes/class-autoloader.php +++ b/includes/class-autoloader.php @@ -40,6 +40,12 @@ 'TON_Reg_Mailchimp' => 'includes/class-mailchimp.php', 'TON_Reg_Gdpr' => 'includes/class-gdpr.php', 'TON_Reg_Documents' => 'includes/class-documents.php', + 'TON_Reg_Members_Export' => 'includes/class-members-export.php', + 'TON_Reg_Membership_Renewals' => 'includes/class-membership-renewals.php', + 'TON_Reg_Renewal_Reminders' => 'includes/class-renewal-reminders.php', + 'TON_Reg_Membership_Card' => 'includes/class-membership-card.php', + 'TON_Reg_Membership_Cards' => 'includes/class-membership-cards.php', + 'TON_Reg_Profile' => 'includes/class-profile.php', 'TON_Reg_Registration_Form' => 'includes/class-registration-form.php', 'TON_Reg_Registration_Handler' => 'includes/class-registration-handler.php', 'TON_Reg_Admin' => 'admin/class-admin.php', diff --git a/includes/class-database.php b/includes/class-database.php index eef39a1..3ffc1d7 100644 --- a/includes/class-database.php +++ b/includes/class-database.php @@ -162,6 +162,57 @@ } /** + * @param WP_User|int|null $user WordPress user or user ID. + * @return object|null + */ + public static function get_for_wp_user( $user ) { + if ( is_numeric( $user ) ) { + $user = get_user_by( 'id', (int) $user ); + } + + if ( ! $user instanceof WP_User ) { + return null; + } + + $row = self::get_by_user_id( (int) $user->ID ); + if ( $row ) { + return $row; + } + + if ( empty( $user->user_email ) ) { + return null; + } + + global $wpdb; + + return $wpdb->get_row( + $wpdb->prepare( + 'SELECT * FROM ' . self::table_name() . ' WHERE email = %s AND anonymized_at IS NULL ORDER BY id DESC LIMIT 1', + $user->user_email + ) + ); + } + + /** + * @param int $user_id WordPress user ID. + * @return object|null + */ + public static function get_by_user_id( $user_id ) { + global $wpdb; + + if ( ! $user_id ) { + return null; + } + + return $wpdb->get_row( + $wpdb->prepare( + 'SELECT * FROM ' . self::table_name() . ' WHERE user_id = %d AND anonymized_at IS NULL ORDER BY id DESC LIMIT 1', + $user_id + ) + ); + } + + /** * @param array $args Query args. * @return array */ @@ -169,18 +220,25 @@ global $wpdb; $defaults = array( - 'status' => '', - 'search' => '', - 'limit' => 20, - 'offset' => 0, - 'orderby' => 'created_at', - 'order' => 'DESC', + 'status' => '', + 'search' => '', + 'exclude_anonymized' => false, + 'renewal_unpaid' => false, + 'renewal_year' => 0, + 'limit' => 20, + 'offset' => 0, + 'orderby' => 'created_at', + 'order' => 'DESC', ); $args = wp_parse_args( $args, $defaults ); $where = array( '1=1' ); $params = array(); + if ( ! empty( $args['exclude_anonymized'] ) ) { + $where[] = 'anonymized_at IS NULL'; + } + if ( ! empty( $args['status'] ) ) { $where[] = 'status = %s'; $params[] = $args['status']; @@ -192,7 +250,21 @@ $params = array_merge( $params, array( $like, $like, $like, $like ) ); } - $allowed_orderby = array( 'created_at', 'cognome', 'nome', 'email', 'status' ); + if ( ! empty( $args['renewal_unpaid'] ) ) { + $renewal_year = ! empty( $args['renewal_year'] ) ? (int) $args['renewal_year'] : TON_Reg_Membership_Renewals::current_year(); + $ren_table = TON_Reg_Membership_Renewals::table_name(); + $reg_table = self::table_name(); + $where[] = "status = 'admitted'"; + $where[] = 'anonymized_at IS NULL'; + $where[] = "NOT EXISTS ( + SELECT 1 FROM {$ren_table} ren + WHERE ren.registration_id = {$reg_table}.id + AND ren.year = {$renewal_year} + AND ren.status = '" . esc_sql( TON_Reg_Membership_Renewals::STATUS_PAID ) . "' + )"; + } + + $allowed_orderby = array( 'id', 'created_at', 'cognome', 'nome', 'email', 'status' ); $orderby = in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'created_at'; $order = 'ASC' === strtoupper( $args['order'] ) ? 'ASC' : 'DESC'; @@ -216,9 +288,22 @@ public static function count( $args = array() ) { global $wpdb; + $defaults = array( + 'status' => '', + 'search' => '', + 'exclude_anonymized' => false, + 'renewal_unpaid' => false, + 'renewal_year' => 0, + ); + $args = wp_parse_args( $args, $defaults ); + $where = array( '1=1' ); $params = array(); + if ( ! empty( $args['exclude_anonymized'] ) ) { + $where[] = 'anonymized_at IS NULL'; + } + if ( ! empty( $args['status'] ) ) { $where[] = 'status = %s'; $params[] = $args['status']; @@ -230,6 +315,20 @@ $params = array_merge( $params, array( $like, $like, $like, $like ) ); } + if ( ! empty( $args['renewal_unpaid'] ) ) { + $renewal_year = ! empty( $args['renewal_year'] ) ? (int) $args['renewal_year'] : TON_Reg_Membership_Renewals::current_year(); + $ren_table = TON_Reg_Membership_Renewals::table_name(); + $reg_table = self::table_name(); + $where[] = "status = 'admitted'"; + $where[] = 'anonymized_at IS NULL'; + $where[] = "NOT EXISTS ( + SELECT 1 FROM {$ren_table} ren + WHERE ren.registration_id = {$reg_table}.id + AND ren.year = {$renewal_year} + AND ren.status = '" . esc_sql( TON_Reg_Membership_Renewals::STATUS_PAID ) . "' + )"; + } + $sql = 'SELECT COUNT(*) FROM ' . self::table_name() . ' WHERE ' . implode( ' AND ', $where ); if ( ! empty( $params ) ) { @@ -238,4 +337,150 @@ return (int) $wpdb->get_var( $sql ); } + + /** + * Query registrations for CSV export (no pagination by default). + * + * @param array $args Query args. + * @return array + */ + public static function query_for_export( $args = array() ) { + global $wpdb; + + $defaults = array( + 'status' => 'admitted', + 'search' => '', + 'exclude_anonymized' => true, + 'limit' => 0, + 'orderby' => 'cognome', + 'order' => 'ASC', + ); + $args = wp_parse_args( $args, $defaults ); + + $where = array( '1=1' ); + $params = array(); + + if ( ! empty( $args['exclude_anonymized'] ) ) { + $where[] = 'anonymized_at IS NULL'; + } + + if ( ! empty( $args['status'] ) ) { + $where[] = 'status = %s'; + $params[] = $args['status']; + } + + if ( ! empty( $args['search'] ) ) { + $like = '%' . $wpdb->esc_like( $args['search'] ) . '%'; + $where[] = '( cognome LIKE %s OR nome LIKE %s OR email LIKE %s OR codice_fiscale LIKE %s )'; + $params = array_merge( $params, array( $like, $like, $like, $like ) ); + } + + $allowed_orderby = array( 'id', 'created_at', 'cognome', 'nome', 'email', 'status', 'libro_associati_date' ); + $orderby = in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'cognome'; + $order = 'ASC' === strtoupper( $args['order'] ) ? 'ASC' : 'DESC'; + + $sql = 'SELECT * FROM ' . self::table_name() . ' WHERE ' . implode( ' AND ', $where ) + . " ORDER BY {$orderby} {$order}, id ASC"; + + if ( ! empty( $args['limit'] ) ) { + $sql .= ' LIMIT %d'; + $params[] = (int) $args['limit']; + } + + if ( ! empty( $params ) ) { + $sql = $wpdb->prepare( $sql, $params ); + } + + return $wpdb->get_results( $sql ); + } + + /** + * Ordered registration IDs for admin prev/next navigation (same filters as query()). + * + * @param array $args Query args. + * @return array + */ + public static function query_ids( $args = array() ) { + global $wpdb; + + $defaults = array( + 'status' => '', + 'search' => '', + 'exclude_anonymized' => false, + 'renewal_unpaid' => false, + 'renewal_year' => 0, + 'orderby' => 'created_at', + 'order' => 'DESC', + ); + $args = wp_parse_args( $args, $defaults ); + + $where = array( '1=1' ); + $params = array(); + + if ( ! empty( $args['exclude_anonymized'] ) ) { + $where[] = 'anonymized_at IS NULL'; + } + + if ( ! empty( $args['status'] ) ) { + $where[] = 'status = %s'; + $params[] = $args['status']; + } + + if ( ! empty( $args['search'] ) ) { + $like = '%' . $wpdb->esc_like( $args['search'] ) . '%'; + $where[] = '( cognome LIKE %s OR nome LIKE %s OR email LIKE %s OR codice_fiscale LIKE %s )'; + $params = array_merge( $params, array( $like, $like, $like, $like ) ); + } + + if ( ! empty( $args['renewal_unpaid'] ) ) { + $renewal_year = ! empty( $args['renewal_year'] ) ? (int) $args['renewal_year'] : TON_Reg_Membership_Renewals::current_year(); + $ren_table = TON_Reg_Membership_Renewals::table_name(); + $reg_table = self::table_name(); + $where[] = "status = 'admitted'"; + $where[] = 'anonymized_at IS NULL'; + $where[] = "NOT EXISTS ( + SELECT 1 FROM {$ren_table} ren + WHERE ren.registration_id = {$reg_table}.id + AND ren.year = {$renewal_year} + AND ren.status = '" . esc_sql( TON_Reg_Membership_Renewals::STATUS_PAID ) . "' + )"; + } + + $allowed_orderby = array( 'id', 'created_at', 'cognome', 'nome', 'email', 'status' ); + $orderby = in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'created_at'; + $order = 'ASC' === strtoupper( $args['order'] ) ? 'ASC' : 'DESC'; + + $sql = 'SELECT id FROM ' . self::table_name() . ' WHERE ' . implode( ' AND ', $where ) + . " ORDER BY {$orderby} {$order}, id ASC"; + + if ( ! empty( $params ) ) { + $sql = $wpdb->prepare( $sql, $params ); + } + + $ids = $wpdb->get_col( $sql ); + return array_map( 'intval', is_array( $ids ) ? $ids : array() ); + } + + /** + * @param int $current_id Current registration ID. + * @param array $args Same filters/order as list view. + * @return array{prev:int|null,next:int|null} + */ + public static function get_adjacent_ids( $current_id, $args = array() ) { + $current_id = (int) $current_id; + $ids = self::query_ids( $args ); + $index = array_search( $current_id, $ids, true ); + + if ( false === $index ) { + return array( + 'prev' => null, + 'next' => null, + ); + } + + return array( + 'prev' => $index > 0 ? (int) $ids[ $index - 1 ] : null, + 'next' => $index < count( $ids ) - 1 ? (int) $ids[ $index + 1 ] : null, + ); + } } diff --git a/includes/class-deactivator.php b/includes/class-deactivator.php index cf1a6d4..a269b65 100644 --- a/includes/class-deactivator.php +++ b/includes/class-deactivator.php @@ -16,6 +16,7 @@ * Deactivate plugin. */ public static function deactivate() { + TON_Reg_Renewal_Reminders::unschedule_cron(); flush_rewrite_rules(); } } diff --git a/includes/class-defaults.php b/includes/class-defaults.php index e23693a..c6bcac4 100644 --- a/includes/class-defaults.php +++ b/includes/class-defaults.php @@ -26,13 +26,21 @@ 'ton_reg_privacy_notice' => self::privacy_notice(), 'ton_reg_newsletter_label' => self::newsletter_label(), 'ton_reg_minor_notice' => self::minor_notice(), - 'ton_reg_success_message' => '

Grazie. La tua domanda di iscrizione è stata ricevuta. Riceverai un\'email di conferma con i prossimi passaggi.

', + 'ton_reg_success_message' => '

Grazie per esserti associato a TON Italia, riceverai una email di conferma.

', + 'ton_reg_success_page_id' => 0, 'ton_reg_admin_email' => get_option( 'admin_email' ), 'ton_reg_gdpr_contact' => 'qeshet.tonitalia@gmail.com', 'ton_reg_email_admin_subject' => '[TON ITALIA ODV] Nuova domanda di iscrizione socio', 'ton_reg_email_admin_body' => self::email_admin_body(), 'ton_reg_email_user_subject' => 'Conferma iscrizione TON ITALIA ODV', 'ton_reg_email_user_body' => self::email_user_body(), + 'ton_reg_email_renewal_first_subject' => 'TON ITALIA ODV — Rinnovo quota associativa {anno}', + 'ton_reg_email_renewal_first_body' => self::email_renewal_first_body(), + 'ton_reg_email_renewal_second_subject' => 'TON ITALIA ODV — Sollecito rinnovo quota associativa {anno}', + 'ton_reg_email_renewal_second_body' => self::email_renewal_second_body(), + 'ton_reg_email_renewal_admin_subject' => '[TON ITALIA ODV] Soci con quota {anno} da pagare ({numero_soci})', + 'ton_reg_email_renewal_admin_body' => self::email_renewal_admin_body(), + 'ton_reg_renewal_reminders_start_year' => '2027', 'ton_reg_captcha_enabled' => '1', 'ton_reg_rate_limit_max' => '5', 'ton_reg_rate_limit_window' => '900', @@ -120,4 +128,38 @@ . "Ai sensi del GDPR puoi richiedere in qualsiasi momento la cancellazione o l'anonimizzazione dei tuoi dati scrivendo a {gdpr_contact}.\n\n" . "Cordiali saluti,\nTON ITALIA ODV"; } + + /** + * @return string + */ + private static function email_renewal_first_body() { + return "Gentile {nome} {cognome},\n\n" + . "ti ricordiamo di effettuare il bonifico per il rinnovo della quota associativa dell'anno {anno}.\n\n" + . "{payment_instructions}\n\n" + . "Dopo il versamento, il Direttivo annoterà il pagamento nel Libro degli associati.\n\n" + . "Il tuo profilo socio: {profile_url}\n\n" + . "Cordiali saluti,\nTON ITALIA ODV"; + } + + /** + * @return string + */ + private static function email_renewal_second_body() { + return "Gentile {nome} {cognome},\n\n" + . "non risulta ancora registrato il pagamento della quota associativa per l'anno {anno}.\n\n" + . "Ti preghiamo di provvedere al più presto secondo le istruzioni seguenti:\n\n" + . "{payment_instructions}\n\n" + . "Per qualsiasi chiarimento puoi scrivere a {gdpr_contact}.\n\n" + . "Cordiali saluti,\nTON ITALIA ODV"; + } + + /** + * @return string + */ + private static function email_renewal_admin_body() { + return "Promemoria rinnovo quote associative per l'anno {anno}.\n\n" + . "Soci ammessi senza quota pagata ({numero_soci}):\n\n" + . "{lista_soci}\n\n" + . "Gestisci le iscrizioni: {admin_url}"; + } } diff --git a/includes/class-documents.php b/includes/class-documents.php index dfc8496..fc0d81a 100644 --- a/includes/class-documents.php +++ b/includes/class-documents.php @@ -1,6 +1,6 @@ */ public static function get_type_config() { @@ -32,15 +39,10 @@ 'label' => __( 'Documento di identità', 'ton-italia-registration' ), 'title' => __( 'Documento identità', 'ton-italia-registration' ), ), - self::TYPE_BONIFICO => array( - 'column' => 'doc_bonifico_id', - 'label' => __( 'Ricevuta bonifico', 'ton-italia-registration' ), - 'title' => __( 'Ricevuta bonifico', 'ton-italia-registration' ), - ), self::TYPE_PAGAMENTO => array( 'column' => 'doc_pagamento_id', - 'label' => __( 'Ricevuta pagamento', 'ton-italia-registration' ), - 'title' => __( 'Ricevuta pagamento', 'ton-italia-registration' ), + 'label' => __( 'Ricevuta versamento tessera', 'ton-italia-registration' ), + 'title' => __( 'Ricevuta versamento tessera', 'ton-italia-registration' ), ), ); } @@ -134,6 +136,65 @@ } /** + * @param object $row Registration row. + * @param string $subfolder Subfolder name (e.g. tessere). + */ + public static function ensure_subfolder( $row, $subfolder ) { + $path = trailingslashit( self::get_member_path( $row ) ) . sanitize_file_name( $subfolder ); + wp_mkdir_p( $path ); + self::write_index_file( $path ); + } + + /** + * @param object $row Registration row. + * @param string $file_path Source file path. + * @param string $filename Target filename. + * @param string $title Attachment title. + * @param string $subfolder Optional subfolder. + * @param array $meta Post meta. + * @return int|WP_Error Attachment ID. + */ + public static function sideload_file( $row, $file_path, $filename, $title, $subfolder = '', $meta = array() ) { + if ( ! is_readable( $file_path ) ) { + return new WP_Error( 'no_file', __( 'File sorgente non leggibile.', 'ton-italia-registration' ) ); + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + + self::ensure_member_folder( $row ); + if ( $subfolder ) { + self::ensure_subfolder( $row, $subfolder ); + } + + $file_array = array( + 'name' => sanitize_file_name( $filename ), + 'tmp_name' => $file_path, + ); + + self::$upload_member_slug = self::member_folder_slug( $row ); + self::$upload_subfolder = $subfolder ? sanitize_file_name( $subfolder ) : null; + add_filter( 'upload_dir', array( __CLASS__, 'filter_upload_dir' ) ); + + $attachment_id = media_handle_sideload( $file_array, 0, $title ); + + remove_filter( 'upload_dir', array( __CLASS__, 'filter_upload_dir' ) ); + self::$upload_member_slug = null; + self::$upload_subfolder = null; + + if ( is_wp_error( $attachment_id ) ) { + return $attachment_id; + } + + foreach ( $meta as $key => $value ) { + update_post_meta( $attachment_id, $key, $value ); + } + + return (int) $attachment_id; + } + + /** * @param int $registration_id Registration ID. * @param string $type Document type. * @param array $file $_FILES item. @@ -253,6 +314,8 @@ self::delete_document( $registration_id, $type ); } + self::delete_legacy_bonifico_document( $registration_id ); + if ( $remove_folder ) { $path = self::get_member_path( $row ); if ( is_dir( $path ) ) { @@ -262,6 +325,25 @@ } /** + * Remove legacy «Ricevuta bonifico» attachment (upload type removed in 1.6.3). + * + * @param int $registration_id Registration ID. + */ + public static function delete_legacy_bonifico_document( $registration_id ) { + $row = TON_Reg_Database::get( $registration_id ); + if ( ! $row || empty( $row->doc_bonifico_id ) ) { + return; + } + + $attachment_id = (int) $row->doc_bonifico_id; + if ( $attachment_id ) { + wp_delete_attachment( $attachment_id, true ); + } + + TON_Reg_Database::update( $registration_id, array( self::LEGACY_COLUMN_BONIFICO => null ) ); + } + + /** * @param array $dirs Upload dirs. * @return array */ @@ -271,6 +353,9 @@ } $subdir = '/' . self::BASE_FOLDER . '/' . self::$upload_member_slug; + if ( self::$upload_subfolder ) { + $subdir .= '/' . self::$upload_subfolder; + } $dirs['subdir'] = $subdir; $dirs['path'] = $dirs['basedir'] . $subdir; $dirs['url'] = $dirs['baseurl'] . $subdir; diff --git a/includes/class-mailer.php b/includes/class-mailer.php index 82483c3..5cc3f33 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -50,6 +50,75 @@ } /** + * @param object $registration Registration row. + * @param int $year Target calendar year. + * @param string $reminder_type first|last. + */ + public static function send_renewal_reminder_to_member( $registration, $year, $reminder_type ) { + $to = $registration->email ?? ''; + if ( ! is_email( $to ) ) { + return; + } + + $subject_key = 'first' === $reminder_type ? 'ton_reg_email_renewal_first_subject' : 'ton_reg_email_renewal_second_subject'; + $body_key = 'first' === $reminder_type ? 'ton_reg_email_renewal_first_body' : 'ton_reg_email_renewal_second_body'; + $defaults = TON_Reg_Defaults::options(); + + $subject = get_option( $subject_key, $defaults[ $subject_key ] ?? '' ); + $body = get_option( $body_key, $defaults[ $body_key ] ?? '' ); + + $replacements = self::renewal_replacements( $registration, (int) $year ); + $subject = strtr( $subject, $replacements ); + $body = strtr( $body, $replacements ); + + wp_mail( $to, $subject, $body ); + } + + /** + * @param array $members Registration rows. + * @param int $year Target calendar year. + * @param string $reminder_type first|last. + */ + public static function send_renewal_admin_digest( $members, $year, $reminder_type ) { + if ( empty( $members ) ) { + return; + } + + $to = get_option( 'ton_reg_admin_email', get_option( 'admin_email' ) ); + if ( ! is_email( $to ) ) { + return; + } + + $defaults = TON_Reg_Defaults::options(); + $subject = get_option( 'ton_reg_email_renewal_admin_subject', $defaults['ton_reg_email_renewal_admin_subject'] ?? '' ); + $body = get_option( 'ton_reg_email_renewal_admin_body', $defaults['ton_reg_email_renewal_admin_body'] ?? '' ); + + $lines = array(); + foreach ( $members as $member ) { + $lines[] = sprintf( + '- %s %s <%s> (ID %d)', + $member->nome ?? '', + $member->cognome ?? '', + $member->email ?? '', + (int) $member->id + ); + } + + $replacements = array( + '{anno}' => (string) (int) $year, + '{lista_soci}' => implode( "\n", $lines ), + '{numero_soci}' => (string) count( $members ), + '{quota}' => (string) get_option( 'ton_reg_membership_fee', '10,00' ), + '{admin_url}' => admin_url( 'admin.php?page=ton-registrations&status=admitted&renewal_unpaid=1' ), + ); + + $subject = strtr( $subject, $replacements ); + $body = strtr( $body, $replacements ); + + wp_mail( $to, $subject, $body ); + } + + /** * @param object $registration Registration. * @param int $user_id User ID. * @return array @@ -64,8 +133,43 @@ '{codice_fiscale}' => $registration->codice_fiscale, '{ip}' => $registration->registration_ip, '{login_url}' => wp_lostpassword_url(), + '{profile_url}' => TON_Reg_Profile::profile_url(), '{gdpr_contact}' => get_option( 'ton_reg_gdpr_contact', '' ), '{admin_url}' => $admin_url, ); } + + /** + * @param object $registration Registration. + * @param int $year Calendar year for payment. + * @return array + */ + private static function renewal_replacements( $registration, $year ) { + $admin_url = admin_url( 'admin.php?page=ton-registrations&action=view&id=' . (int) $registration->id ); + $payment = get_option( 'ton_reg_payment_instructions', '' ); + + $payment_replacements = array( + '{quota}' => (string) get_option( 'ton_reg_membership_fee', '10,00' ), + '{anno}' => (string) $year, + '{beneficiario}' => (string) get_option( 'ton_reg_beneficiary', '' ), + '{iban}' => (string) get_option( 'ton_reg_iban', '' ), + '{nome}' => (string) ( $registration->nome ?? '' ), + '{cognome}' => (string) ( $registration->cognome ?? '' ), + ); + $payment_instructions = strtr( $payment, $payment_replacements ); + + return array_merge( + $payment_replacements, + array( + '{email}' => (string) ( $registration->email ?? '' ), + '{codice_fiscale}' => (string) ( $registration->codice_fiscale ?? '' ), + '{ip}' => (string) ( $registration->registration_ip ?? '' ), + '{login_url}' => wp_lostpassword_url(), + '{profile_url}' => TON_Reg_Profile::profile_url(), + '{gdpr_contact}' => (string) get_option( 'ton_reg_gdpr_contact', '' ), + '{admin_url}' => $admin_url, + '{payment_instructions}' => $payment_instructions, + ) + ); + } } diff --git a/includes/class-members-export.php b/includes/class-members-export.php new file mode 100644 index 0000000..91747d5 --- /dev/null +++ b/includes/class-members-export.php @@ -0,0 +1,122 @@ + + */ + public static function get_columns() { + return array( + __( 'N.', 'ton-italia-registration' ), + __( 'Cognome', 'ton-italia-registration' ), + __( 'Nome', 'ton-italia-registration' ), + __( 'Data di nascita', 'ton-italia-registration' ), + __( 'Luogo di nascita', 'ton-italia-registration' ), + __( 'Provincia nascita', 'ton-italia-registration' ), + __( 'Codice fiscale', 'ton-italia-registration' ), + __( 'Indirizzo', 'ton-italia-registration' ), + __( 'CAP', 'ton-italia-registration' ), + __( 'Comune residenza', 'ton-italia-registration' ), + __( 'Provincia residenza', 'ton-italia-registration' ), + __( 'Telefono', 'ton-italia-registration' ), + __( 'Email', 'ton-italia-registration' ), + __( 'Data iscrizione', 'ton-italia-registration' ), + __( 'Data Libro Associati', 'ton-italia-registration' ), + ); + } + + /** + * @param object $row Registration row. + * @param int $index 1-based row number. + * @return array + */ + public static function row_to_csv( $row, $index ) { + $address = trim( ( $row->indirizzo ?? '' ) . ' ' . ( $row->numero_civico ?? '' ) ); + + return array( + (string) $index, + (string) ( $row->cognome ?? '' ), + (string) ( $row->nome ?? '' ), + self::format_date_it( $row->data_nascita ?? '' ), + (string) ( $row->luogo_nascita ?? '' ), + (string) ( $row->provincia_nascita ?? '' ), + strtoupper( (string) ( $row->codice_fiscale ?? '' ) ), + $address, + (string) ( $row->cap ?? '' ), + (string) ( $row->comune_residenza ?? '' ), + (string) ( $row->provincia_residenza ?? '' ), + (string) ( $row->telefono ?? '' ), + (string) ( $row->email ?? '' ), + self::format_date_it( $row->created_at ?? '' ), + self::format_date_it( $row->libro_associati_date ?? '' ), + ); + } + + /** + * Stream Libro Soci CSV to output. + */ + public static function stream_libro_soci_csv() { + $rows = TON_Reg_Database::query_for_export( + array( + 'status' => 'admitted', + 'exclude_anonymized' => true, + 'orderby' => 'cognome', + 'order' => 'ASC', + ) + ); + + $filename = 'libro-soci-ton-italia-' . gmdate( 'Y-m-d' ) . '.csv'; + + nocache_headers(); + header( 'Content-Type: text/csv; charset=utf-8' ); + header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $filename ) . '"' ); + + $out = fopen( 'php://output', 'w' ); + if ( false === $out ) { + wp_die( esc_html__( 'Impossibile generare il file CSV.', 'ton-italia-registration' ) ); + } + + // UTF-8 BOM for Excel (IT). + fprintf( $out, chr( 0xEF ) . chr( 0xBB ) . chr( 0xBF ) ); + + fputcsv( $out, self::get_columns(), self::CSV_DELIMITER ); + + $index = 1; + foreach ( $rows as $row ) { + fputcsv( $out, self::row_to_csv( $row, $index ), self::CSV_DELIMITER ); + ++$index; + } + + fclose( $out ); + exit; + } + + /** + * @param string|null $value Date or datetime. + * @return string + */ + private static function format_date_it( $value ) { + if ( empty( $value ) ) { + return ''; + } + + $timestamp = strtotime( (string) $value ); + if ( false === $timestamp ) { + return ''; + } + + return wp_date( 'd/m/Y', $timestamp ); + } +} diff --git a/includes/class-membership-card.php b/includes/class-membership-card.php new file mode 100644 index 0000000..2189700 --- /dev/null +++ b/includes/class-membership-card.php @@ -0,0 +1,606 @@ + 2100 ) { + $year = (int) gmdate( 'Y' ); + } + return $year; + } + + /** + * @param int $year Year. + * @return void + */ + public static function set_active_year( $year ) { + update_option( self::OPTION_ACTIVE_YEAR, (int) $year ); + } + + /** + * @return array> + */ + public static function get_all_templates() { + $templates = get_option( self::OPTION_TEMPLATES, array() ); + return is_array( $templates ) ? $templates : array(); + } + + /** + * @param int $year Year. + * @return array + */ + public static function get_template_config( $year ) { + $templates = self::get_all_templates(); + $config = $templates[ (int) $year ] ?? array(); + return wp_parse_args( $config, self::default_layout() ); + } + + /** + * @return array + */ + public static function default_layout() { + return array( + 'template_attachment_id' => 0, + 'name_x' => 42.5, + 'name_y' => 118.0, + 'font_family' => 'Helvetica', + 'font_style' => '', + 'font_size' => 14, + 'text_color' => '#000000', + 'uppercase' => true, + 'align' => 'C', + 'cell_width' => 80, + ); + } + + /** + * @param int $year Year. + * @param array $config Config. + */ + public static function save_template_config( $year, $config ) { + $year = (int) $year; + $templates = self::get_all_templates(); + $current = $templates[ $year ] ?? array(); + + $templates[ $year ] = array_merge( self::default_layout(), $current, $config ); + update_option( self::OPTION_TEMPLATES, $templates ); + } + + /** + * Upload a PDF card template to the media library. + * + * Uses wp_handle_upload + wp_insert_attachment without image metadata generation, + * which avoids fatal errors from Imagick/GD on some hosts when processing PDFs. + * + * @param array|null $file Uploaded file array from $_FILES. + * @param int $year Template year. + * @return int|WP_Error Attachment ID. + */ + public static function upload_template_pdf( $file, $year ) { + if ( ! self::current_user_can_manage_cards() ) { + return new WP_Error( 'forbidden', __( 'Non autorizzato.', 'ton-italia-registration' ) ); + } + + if ( ! is_array( $file ) || empty( $file['tmp_name'] ) ) { + if ( is_array( $file ) && isset( $file['error'] ) && UPLOAD_ERR_NO_FILE !== (int) $file['error'] ) { + return new WP_Error( 'upload', self::upload_error_message( (int) $file['error'] ) ); + } + return new WP_Error( 'no_file', __( 'Nessun file selezionato.', 'ton-italia-registration' ) ); + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + + $check = wp_check_filetype( $file['name'], array( 'pdf' => 'application/pdf' ) ); + if ( empty( $check['ext'] ) || 'pdf' !== $check['ext'] ) { + return new WP_Error( 'mime', __( 'Il template deve essere un file PDF.', 'ton-italia-registration' ) ); + } + + $upload = wp_handle_upload( + $file, + array( + 'test_form' => false, + 'mimes' => array( + 'pdf' => 'application/pdf', + ), + ) + ); + + if ( ! empty( $upload['error'] ) ) { + return new WP_Error( 'upload', $upload['error'] ); + } + + $attachment_id = wp_insert_attachment( + array( + 'post_mime_type' => $upload['type'], + 'post_title' => sprintf( + /* translators: %d: year */ + __( 'Template tessera %d', 'ton-italia-registration' ), + (int) $year + ), + 'post_content' => '', + 'post_status' => 'inherit', + 'guid' => $upload['url'], + ), + $upload['file'] + ); + + if ( is_wp_error( $attachment_id ) ) { + wp_delete_file( $upload['file'] ); + return $attachment_id; + } + + wp_update_attachment_metadata( $attachment_id, array() ); + + return (int) $attachment_id; + } + + /** + * @param int $code PHP upload error code. + * @return string + */ + private static function upload_error_message( $code ) { + $messages = array( + UPLOAD_ERR_INI_SIZE => __( 'Il file supera il limite di upload del server.', 'ton-italia-registration' ), + UPLOAD_ERR_FORM_SIZE => __( 'Il file supera il limite consentito.', 'ton-italia-registration' ), + UPLOAD_ERR_PARTIAL => __( 'Upload incompleto. Riprova.', 'ton-italia-registration' ), + UPLOAD_ERR_NO_FILE => __( 'Nessun file selezionato.', 'ton-italia-registration' ), + UPLOAD_ERR_NO_TMP_DIR => __( 'Cartella temporanea mancante sul server.', 'ton-italia-registration' ), + UPLOAD_ERR_CANT_WRITE => __( 'Impossibile scrivere il file sul server.', 'ton-italia-registration' ), + UPLOAD_ERR_EXTENSION => __( 'Upload bloccato da un\'estensione PHP.', 'ton-italia-registration' ), + ); + + return $messages[ $code ] ?? __( 'Errore nel caricamento del template.', 'ton-italia-registration' ); + } + + /** + * @param int $registration_id Registration ID. + * @param int $year Year. + * @param array $args Args. + * @return int|WP_Error Card ID. + */ + public static function generate_for_registration( $registration_id, $year, $args = array() ) { + $defaults = array( + 'context' => self::CONTEXT_MANUAL, + 'force_regenerate' => false, + 'created_by' => get_current_user_id(), + 'require_admin' => true, + ); + $args = wp_parse_args( $args, $defaults ); + + if ( $args['require_admin'] && ! self::current_user_can_manage_cards() ) { + return new WP_Error( 'forbidden', __( 'Non autorizzato.', 'ton-italia-registration' ) ); + } + + $row = TON_Reg_Database::get( $registration_id ); + if ( ! $row ) { + return new WP_Error( 'not_found', __( 'Iscrizione non trovata.', 'ton-italia-registration' ) ); + } + + if ( 'admitted' !== $row->status || ! empty( $row->anonymized_at ) ) { + return new WP_Error( 'not_admitted', __( 'La tessera può essere generata solo per soci ammessi.', 'ton-italia-registration' ) ); + } + + $year = (int) $year; + $config = self::get_template_config( $year ); + + if ( empty( $config['template_attachment_id'] ) ) { + return new WP_Error( 'no_template', __( 'Template PDF non configurato per questo anno.', 'ton-italia-registration' ) ); + } + + $existing = TON_Reg_Membership_Cards::get_by_registration_year( $registration_id, $year ); + if ( $existing && empty( $args['force_regenerate'] ) ) { + return (int) $existing->id; + } + + $library = self::load_library(); + if ( is_wp_error( $library ) ) { + return $library; + } + + $pdf_path = self::build_pdf_file( $row, $year, $config ); + if ( is_wp_error( $pdf_path ) ) { + return $pdf_path; + } + + $attachment_id = self::save_pdf_attachment( $row, $year, $pdf_path ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + @unlink( $pdf_path ); + + if ( is_wp_error( $attachment_id ) ) { + return $attachment_id; + } + + if ( $existing ) { + if ( ! empty( $existing->attachment_id ) && (int) $existing->attachment_id !== (int) $attachment_id ) { + wp_delete_attachment( (int) $existing->attachment_id, true ); + } + + TON_Reg_Membership_Cards::update( + (int) $existing->id, + array( + 'attachment_id' => (int) $attachment_id, + 'template_attachment_id' => (int) $config['template_attachment_id'], + 'layout_snapshot' => wp_json_encode( $config ), + 'generation_context' => sanitize_key( $args['context'] ), + 'created_at' => current_time( 'mysql', true ), + 'created_by' => (int) $args['created_by'], + ) + ); + + return (int) $existing->id; + } + + $card_id = TON_Reg_Membership_Cards::insert( + array( + 'registration_id' => (int) $registration_id, + 'user_id' => (int) ( $row->user_id ?? 0 ) ?: null, + 'year' => $year, + 'attachment_id' => (int) $attachment_id, + 'template_attachment_id' => (int) $config['template_attachment_id'], + 'layout_snapshot' => wp_json_encode( $config ), + 'generation_context' => sanitize_key( $args['context'] ), + 'created_by' => (int) $args['created_by'], + ) + ); + + if ( ! $card_id ) { + wp_delete_attachment( (int) $attachment_id, true ); + return new WP_Error( 'db_error', __( 'Impossibile salvare la tessera nel database.', 'ton-italia-registration' ) ); + } + + return (int) $card_id; + } + + /** + * @param int $registration_id Registration ID. + * @param string $old_status Previous status. + * @param string $new_status New status. + * @return int|WP_Error|null Card ID, error, or null if skipped. + */ + public static function maybe_generate_on_admission( $registration_id, $old_status, $new_status ) { + if ( 'admitted' === $old_status || 'admitted' !== $new_status ) { + return null; + } + + $year = self::get_active_year(); + if ( TON_Reg_Membership_Cards::get_by_registration_year( $registration_id, $year ) ) { + return null; + } + + return self::generate_for_registration( + $registration_id, + self::get_active_year(), + array( + 'context' => self::CONTEXT_ADMISSION, + 'require_admin' => true, + ) + ); + } + + /** + * @param object $row Registration row. + * @param int $year Year. + * @param array $config Layout config. + * @return string|WP_Error Temp file path. + */ + private static function build_pdf_file( $row, $year, $config ) { + $template_path = get_attached_file( (int) $config['template_attachment_id'] ); + if ( ! $template_path || ! is_readable( $template_path ) ) { + return new WP_Error( 'template_missing', __( 'File template PDF non trovato.', 'ton-italia-registration' ) ); + } + + try { + $pdf = new Fpdi(); + $pdf->setSourceFile( $template_path ); + $tpl_id = $pdf->importPage( 1 ); + $size = $pdf->getTemplateSize( $tpl_id ); + + $orientation = ( $size['width'] > $size['height'] ) ? 'L' : 'P'; + $pdf->AddPage( $orientation, array( $size['width'], $size['height'] ) ); + $pdf->useTemplate( $tpl_id ); + + $family = self::sanitize_font_family( (string) $config['font_family'] ); + $style = self::sanitize_font_style( (string) $config['font_style'] ); + $size_pt = max( 6, min( 72, (float) $config['font_size'] ) ); + + $pdf->SetFont( $family, $style, $size_pt ); + + list( $r, $g, $b ) = self::parse_hex_color( (string) $config['text_color'] ); + $pdf->SetTextColor( $r, $g, $b ); + + self::write_name_on_pdf( $pdf, $row, $config, $size_pt ); + + $tmp = wp_tempnam( 'ton-card-' . $year ); + if ( ! $tmp ) { + return new WP_Error( 'temp_file', __( 'Impossibile creare file temporaneo.', 'ton-italia-registration' ) ); + } + + $pdf->Output( 'F', $tmp ); + return $tmp; + } catch ( Exception $e ) { + return new WP_Error( 'pdf_error', $e->getMessage() ); + } + } + + /** + * @param object $row Registration row. + * @param int $year Year. + * @param string $path Temp PDF path. + * @return int|WP_Error Attachment ID. + */ + private static function save_pdf_attachment( $row, $year, $path ) { + TON_Reg_Documents::ensure_member_folder( $row ); + TON_Reg_Documents::ensure_subfolder( $row, self::SUBFOLDER ); + + $filename = sprintf( 'Tessera-%d.pdf', (int) $year ); + $title = sprintf( + /* translators: 1: year, 2: full name */ + __( 'Tessera %1$d — %2$s', 'ton-italia-registration' ), + (int) $year, + $row->nome . ' ' . $row->cognome + ); + + return TON_Reg_Documents::sideload_file( + $row, + $path, + $filename, + $title, + self::SUBFOLDER, + array( + '_ton_reg_registration_id' => (int) $row->id, + '_ton_reg_document_type' => 'tessera', + '_ton_reg_card_year' => (int) $year, + ) + ); + } + + /** + * Write member name on the PDF, splitting onto two lines when needed. + * + * @param Fpdi $pdf PDF instance. + * @param object $row Registration row. + * @param array $config Layout config. + * @param float $size_pt Font size in points. + */ + private static function write_name_on_pdf( $pdf, $row, $config, $size_pt ) { + $parts = self::get_name_parts( $row, $config ); + $nome = $parts['nome']; + $cognome = $parts['cognome']; + $full_name = trim( $nome . ( $cognome ? ' ' . $cognome : '' ) ); + $align = in_array( $config['align'], array( 'L', 'C', 'R' ), true ) ? $config['align'] : 'C'; + $cell_width = max( 0, (float) $config['cell_width'] ); + $x = (float) $config['name_x']; + $y = (float) $config['name_y']; + + $use_two_lines = false; + if ( $cell_width > 0 && '' !== $cognome ) { + $full_width = $pdf->GetStringWidth( $full_name ); + if ( $full_width > $cell_width ) { + $use_two_lines = true; + } + } + + if ( $use_two_lines ) { + $line_height = max( 4.0, $size_pt * 0.45 ); + $pdf->SetXY( $x, $y ); + $pdf->Cell( $cell_width, $line_height, $nome, 0, 2, $align ); + $pdf->SetX( $x ); + $pdf->Cell( $cell_width, $line_height, $cognome, 0, 0, $align ); + return; + } + + $pdf->SetXY( $x, $y ); + $pdf->Cell( $cell_width, 0, $full_name, 0, 0, $align ); + } + + /** + * @param object $row Registration row. + * @param array $config Layout config. + * @return array{nome:string,cognome:string} + */ + private static function get_name_parts( $row, $config ) { + return array( + 'nome' => self::format_name_part( (string) ( $row->nome ?? '' ), $config ), + 'cognome' => self::format_name_part( (string) ( $row->cognome ?? '' ), $config ), + ); + } + + /** + * @param string $text Name part. + * @param array $config Layout config. + * @return string + */ + private static function format_name_part( $text, $config ) { + $text = trim( $text ); + if ( ! empty( $config['uppercase'] ) ) { + if ( function_exists( 'mb_strtoupper' ) ) { + $text = mb_strtoupper( $text, 'UTF-8' ); + } else { + $text = strtoupper( $text ); + } + } + return $text; + } + + /** + * @param object $row Registration row. + * @param array $config Layout config. + * @return string + */ + private static function get_display_name( $row, $config ) { + $parts = self::get_name_parts( $row, $config ); + return trim( $parts['nome'] . ' ' . $parts['cognome'] ); + } + + /** + * @param string $hex Hex color. + * @return array{0:int,1:int,2:int} + */ + private static function parse_hex_color( $hex ) { + $hex = ltrim( trim( $hex ), '#' ); + if ( 3 === strlen( $hex ) ) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) { + return array( 0, 0, 0 ); + } + return array( + (int) hexdec( substr( $hex, 0, 2 ) ), + (int) hexdec( substr( $hex, 2, 2 ) ), + (int) hexdec( substr( $hex, 4, 2 ) ), + ); + } + + /** + * @param string $family Font family. + * @return string + */ + private static function sanitize_font_family( $family ) { + $allowed = array( 'Helvetica', 'Times', 'Courier', 'Arial' ); + return in_array( $family, $allowed, true ) ? ( 'Arial' === $family ? 'Helvetica' : $family ) : 'Helvetica'; + } + + /** + * @param string $style Font style. + * @return string + */ + private static function sanitize_font_style( $style ) { + $allowed = array( '', 'B', 'I', 'BI', 'IB' ); + $style = strtoupper( $style ); + if ( 'IB' === $style ) { + $style = 'BI'; + } + return in_array( $style, $allowed, true ) ? $style : ''; + } + + /** + * @param int $card_id Card ID. + * @return bool|WP_Error + */ + public static function current_user_can_download( $card_id ) { + $card = TON_Reg_Membership_Cards::get( $card_id ); + if ( ! $card ) { + return new WP_Error( 'not_found', __( 'Tessera non trovata.', 'ton-italia-registration' ) ); + } + + $row = TON_Reg_Database::get( (int) $card->registration_id ); + + if ( self::current_user_can_manage_cards() ) { + return true; + } + + if ( ! is_user_logged_in() ) { + return new WP_Error( 'login', __( 'Accedi per scaricare la tessera.', 'ton-italia-registration' ) ); + } + + $user_row = TON_Reg_Database::get_for_wp_user( wp_get_current_user() ); + if ( ! $user_row || (int) $user_row->id !== (int) $card->registration_id ) { + return new WP_Error( 'forbidden', __( 'Non autorizzato.', 'ton-italia-registration' ) ); + } + + if ( ! $row || 'admitted' !== $row->status || ! empty( $row->anonymized_at ) ) { + return new WP_Error( 'forbidden', __( 'Non autorizzato.', 'ton-italia-registration' ) ); + } + + return true; + } + + /** + * @param int $card_id Card ID. + * @return void + */ + public static function stream_download( $card_id ) { + $can = self::current_user_can_download( $card_id ); + if ( is_wp_error( $can ) ) { + wp_die( esc_html( $can->get_error_message() ), 403 ); + } + + $card = TON_Reg_Membership_Cards::get( $card_id ); + $path = get_attached_file( (int) $card->attachment_id ); + + if ( ! $path || ! is_readable( $path ) ) { + wp_die( esc_html__( 'File non trovato.', 'ton-italia-registration' ), 404 ); + } + + $filename = basename( $path ); + nocache_headers(); + header( 'Content-Type: application/pdf' ); + header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $filename ) . '"' ); + header( 'Content-Length: ' . (string) filesize( $path ) ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile + readfile( $path ); + exit; + } + + /** + * @param string $context Generation context key. + * @return string + */ + public static function context_label( $context ) { + $labels = array( + self::CONTEXT_ADMISSION => __( 'Ammissione', 'ton-italia-registration' ), + self::CONTEXT_BATCH => __( 'Batch annuale', 'ton-italia-registration' ), + self::CONTEXT_MANUAL => __( 'Manuale', 'ton-italia-registration' ), + ); + return $labels[ $context ] ?? $context; + } +} diff --git a/includes/class-membership-cards.php b/includes/class-membership-cards.php new file mode 100644 index 0000000..2f15ba0 --- /dev/null +++ b/includes/class-membership-cards.php @@ -0,0 +1,182 @@ +prefix . 'ton_reg_membership_cards'; + } + + /** + * Create membership cards table. + */ + public static function create_table() { + global $wpdb; + + $table = self::table_name(); + $charset = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + registration_id bigint(20) unsigned NOT NULL, + user_id bigint(20) unsigned DEFAULT NULL, + year smallint(5) unsigned NOT NULL, + attachment_id bigint(20) unsigned NOT NULL, + template_attachment_id bigint(20) unsigned NOT NULL DEFAULT 0, + layout_snapshot longtext NULL, + generation_context varchar(20) NOT NULL DEFAULT 'manual', + created_at datetime NOT NULL, + created_by bigint(20) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY registration_year (registration_id, year), + KEY user_id (user_id), + KEY year (year) + ) {$charset};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + } + + /** + * @param array $data Row data. + * @return int|false + */ + public static function insert( $data ) { + global $wpdb; + + if ( empty( $data['created_at'] ) ) { + $data['created_at'] = current_time( 'mysql', true ); + } + + $result = $wpdb->insert( self::table_name(), $data ); + return false === $result ? false : (int) $wpdb->insert_id; + } + + /** + * @param int $id Card ID. + * @param array $data Data. + * @return bool + */ + public static function update( $id, $data ) { + global $wpdb; + + return false !== $wpdb->update( + self::table_name(), + $data, + array( 'id' => $id ), + null, + array( '%d' ) + ); + } + + /** + * @param int $id Card ID. + * @return object|null + */ + public static function get( $id ) { + global $wpdb; + + return $wpdb->get_row( + $wpdb->prepare( + 'SELECT * FROM ' . self::table_name() . ' WHERE id = %d', + $id + ) + ); + } + + /** + * @param int $registration_id Registration ID. + * @param int $year Card year. + * @return object|null + */ + public static function get_by_registration_year( $registration_id, $year ) { + global $wpdb; + + return $wpdb->get_row( + $wpdb->prepare( + 'SELECT * FROM ' . self::table_name() . ' WHERE registration_id = %d AND year = %d', + $registration_id, + $year + ) + ); + } + + /** + * @param int $registration_id Registration ID. + * @return array + */ + public static function get_by_registration( $registration_id ) { + global $wpdb; + + return $wpdb->get_results( + $wpdb->prepare( + 'SELECT * FROM ' . self::table_name() . ' WHERE registration_id = %d ORDER BY year DESC', + $registration_id + ) + ); + } + + /** + * @param int $user_id WordPress user ID. + * @return array + */ + public static function get_by_user_id( $user_id ) { + global $wpdb; + + if ( ! $user_id ) { + return array(); + } + + return $wpdb->get_results( + $wpdb->prepare( + 'SELECT * FROM ' . self::table_name() . ' WHERE user_id = %d ORDER BY year DESC', + $user_id + ) + ); + } + + /** + * @param int $id Card ID. + * @return bool + */ + public static function delete( $id ) { + global $wpdb; + + return false !== $wpdb->delete( + self::table_name(), + array( 'id' => $id ), + array( '%d' ) + ); + } + + /** + * Delete card row and its attachment. + * + * @param int $id Card ID. + */ + public static function delete_with_attachment( $id ) { + $card = self::get( $id ); + if ( ! $card ) { + return; + } + + if ( ! empty( $card->attachment_id ) ) { + wp_delete_attachment( (int) $card->attachment_id, true ); + } + + self::delete( $id ); + } +} diff --git a/includes/class-membership-renewals.php b/includes/class-membership-renewals.php new file mode 100644 index 0000000..fc74261 --- /dev/null +++ b/includes/class-membership-renewals.php @@ -0,0 +1,393 @@ +prefix . 'ton_reg_membership_renewals'; + } + + /** + * Create renewals table. + */ + public static function create_table() { + global $wpdb; + + $table = self::table_name(); + $charset = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + registration_id bigint(20) unsigned NOT NULL, + year smallint(5) unsigned NOT NULL, + status varchar(20) NOT NULL DEFAULT 'pending', + paid_at datetime DEFAULT NULL, + paid_by bigint(20) unsigned DEFAULT NULL, + reminder_first_jan_sent_at datetime DEFAULT NULL, + reminder_last_jan_sent_at datetime DEFAULT NULL, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY registration_year (registration_id, year), + KEY year (year), + KEY status (status) + ) {$charset};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + } + + /** + * Current calendar year (site timezone). + * + * @return int + */ + public static function current_year() { + return (int) wp_date( 'Y' ); + } + + /** + * @param object $registration Registration row. + * @return int + */ + public static function admission_year( $registration ) { + if ( ! empty( $registration->libro_associati_date ) ) { + $timestamp = strtotime( (string) $registration->libro_associati_date ); + if ( $timestamp ) { + return (int) wp_date( 'Y', $timestamp ); + } + } + + if ( ! empty( $registration->created_at ) ) { + $timestamp = strtotime( (string) $registration->created_at ); + if ( $timestamp ) { + return (int) wp_date( 'Y', $timestamp ); + } + } + + return self::current_year(); + } + + /** + * Years shown in admin checkboxes: from admission through current calendar year only. + * + * @param object $registration Registration row. + * @return array{start:int,end:int} + */ + public static function get_checkbox_year_range( $registration ) { + $start = self::admission_year( $registration ); + $end = self::current_year(); + + if ( $start > $end ) { + $start = $end; + } + + return array( + 'start' => $start, + 'end' => $end, + ); + } + + /** + * @param int $registration_id Registration ID. + * @param int $year Calendar year. + * @return string paid|pending + */ + public static function get_status( $registration_id, $year ) { + $row = self::get_by_registration_year( $registration_id, $year ); + if ( ! $row || self::STATUS_PAID !== (string) $row->status ) { + return self::STATUS_PENDING; + } + + return self::STATUS_PAID; + } + + /** + * @param int $registration_id Registration ID. + * @param int $year Calendar year. + * @return object|null + */ + public static function get_by_registration_year( $registration_id, $year ) { + global $wpdb; + + return $wpdb->get_row( + $wpdb->prepare( + 'SELECT * FROM ' . self::table_name() . ' WHERE registration_id = %d AND year = %d', + $registration_id, + $year + ) + ); + } + + /** + * @param int $registration_id Registration ID. + * @return array + */ + public static function get_by_registration( $registration_id ) { + global $wpdb; + + return $wpdb->get_results( + $wpdb->prepare( + 'SELECT * FROM ' . self::table_name() . ' WHERE registration_id = %d ORDER BY year ASC', + $registration_id + ) + ); + } + + /** + * @param array $registration_ids Registration IDs. + * @param int $year Calendar year. + * @return array Map registration_id => status. + */ + public static function get_statuses_for_registrations( array $registration_ids, $year ) { + global $wpdb; + + $registration_ids = array_values( array_filter( array_map( 'intval', $registration_ids ) ) ); + if ( empty( $registration_ids ) ) { + return array(); + } + + $placeholders = implode( ',', array_fill( 0, count( $registration_ids ), '%d' ) ); + $params = array_merge( array( (int) $year ), $registration_ids ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $sql = $wpdb->prepare( + 'SELECT registration_id, status FROM ' . self::table_name() . " WHERE year = %d AND registration_id IN ({$placeholders})", + $params + ); + + $rows = $wpdb->get_results( $sql ); + $map = array(); + + foreach ( $registration_ids as $id ) { + $map[ $id ] = self::STATUS_PENDING; + } + + foreach ( $rows as $row ) { + $map[ (int) $row->registration_id ] = (string) $row->status; + } + + return $map; + } + + /** + * @param int $registration_id Registration ID. + * @param int $year Calendar year. + * @param bool $paid Whether fee is paid. + * @param int $user_id Admin user marking payment. + * @return bool + */ + public static function set_paid( $registration_id, $year, $paid, $user_id = 0 ) { + global $wpdb; + + $registration_id = (int) $registration_id; + $year = (int) $year; + $user_id = (int) $user_id; + $now = current_time( 'mysql', true ); + $existing = self::get_by_registration_year( $registration_id, $year ); + + $data = array( + 'status' => $paid ? self::STATUS_PAID : self::STATUS_PENDING, + 'paid_at' => $paid ? $now : null, + 'paid_by' => $paid ? ( $user_id ?: null ) : null, + 'updated_at' => $now, + ); + + if ( $existing ) { + return false !== $wpdb->update( + self::table_name(), + $data, + array( 'id' => (int) $existing->id ), + null, + array( '%d' ) + ); + } + + $data['registration_id'] = $registration_id; + $data['year'] = $year; + $data['created_at'] = $now; + + return false !== $wpdb->insert( self::table_name(), $data ); + } + + /** + * Ensure a pending row exists for reminder tracking. + * + * @param int $registration_id Registration ID. + * @param int $year Calendar year. + * @return object|null + */ + public static function ensure_pending_row( $registration_id, $year ) { + $existing = self::get_by_registration_year( $registration_id, $year ); + if ( $existing ) { + return $existing; + } + + $now = current_time( 'mysql', true ); + global $wpdb; + + $wpdb->insert( + self::table_name(), + array( + 'registration_id' => (int) $registration_id, + 'year' => (int) $year, + 'status' => self::STATUS_PENDING, + 'created_at' => $now, + 'updated_at' => $now, + ) + ); + + return self::get_by_registration_year( $registration_id, $year ); + } + + /** + * @param int $registration_id Registration ID. + * @param int $year Calendar year. + * @param string $reminder_type first|last. + * @return bool + */ + public static function mark_reminder_sent( $registration_id, $year, $reminder_type ) { + global $wpdb; + + $row = self::ensure_pending_row( $registration_id, $year ); + if ( ! $row ) { + return false; + } + + $column = 'first' === $reminder_type ? 'reminder_first_jan_sent_at' : 'reminder_last_jan_sent_at'; + $now = current_time( 'mysql', true ); + + return false !== $wpdb->update( + self::table_name(), + array( + $column => $now, + 'updated_at' => $now, + ), + array( 'id' => (int) $row->id ), + null, + array( '%d' ) + ); + } + + /** + * Admitted members without paid renewal for a given year. + * + * @param int $year Calendar year. + * @param string $reminder_type first|last — skip if that reminder was already sent. + * @return array Registration rows. + */ + public static function get_unpaid_admitted( $year, $reminder_type = '' ) { + global $wpdb; + + $reg_table = TON_Reg_Database::table_name(); + $ren_table = self::table_name(); + $year = (int) $year; + + $sql = "SELECT r.* FROM {$reg_table} r + LEFT JOIN {$ren_table} ren ON ren.registration_id = r.id AND ren.year = %d + WHERE r.status = 'admitted' + AND r.anonymized_at IS NULL + AND r.email <> '' + AND ( ren.id IS NULL OR ren.status <> %s )"; + + $params = array( $year, self::STATUS_PAID ); + + if ( 'first' === $reminder_type ) { + $sql .= ' AND ( ren.reminder_first_jan_sent_at IS NULL )'; + } elseif ( 'last' === $reminder_type ) { + $sql .= ' AND ( ren.reminder_last_jan_sent_at IS NULL )'; + } + + $sql .= ' ORDER BY r.cognome ASC, r.nome ASC, r.id ASC'; + + return $wpdb->get_results( $wpdb->prepare( $sql, $params ) ); + } + + /** + * Registration IDs with pending renewal for a year. + * + * @param int $year Calendar year. + * @return array + */ + public static function get_unpaid_registration_ids( $year ) { + $rows = self::get_unpaid_admitted( $year ); + return array_map( + static function ( $row ) { + return (int) $row->id; + }, + $rows + ); + } + + /** + * Display label and CSS class for list table badge. + * + * @param string $status Renewal status (paid|pending). + * @param int $year Calendar year shown. + * @return array{label:string,class:string} + */ + public static function display_badge( $status, $year ) { + $year = (int) $year; + + if ( self::STATUS_PAID === $status ) { + return array( + 'label' => sprintf( + /* translators: %d: calendar year */ + __( 'Pagato %d', 'ton-italia-registration' ), + $year + ), + 'class' => 'ton-reg-renewal--paid', + ); + } + + $month = (int) wp_date( 'n' ); + $day = (int) wp_date( 'j' ); + + if ( 1 === $month && $day >= 25 ) { + return array( + 'label' => sprintf( + /* translators: %d: calendar year */ + __( 'Sollecito %d', 'ton-italia-registration' ), + $year + ), + 'class' => 'ton-reg-renewal--reminder', + ); + } + + if ( $month >= 2 ) { + return array( + 'label' => sprintf( + /* translators: %d: calendar year */ + __( 'Non pagato %d', 'ton-italia-registration' ), + $year + ), + 'class' => 'ton-reg-renewal--overdue', + ); + } + + return array( + 'label' => sprintf( + /* translators: %d: calendar year */ + __( 'Da pagare %d', 'ton-italia-registration' ), + $year + ), + 'class' => 'ton-reg-renewal--pending', + ); + } +} diff --git a/includes/class-plugin.php b/includes/class-plugin.php index f6e5af3..034de65 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -35,11 +35,15 @@ add_action( 'init', array( $this, 'init' ) ); add_action( 'admin_init', array( $this, 'block_subscriber_admin' ) ); + add_action( 'admin_menu', array( $this, 'trim_subscriber_admin_menu' ), 999 ); + add_action( 'load-index.php', array( $this, 'redirect_subscriber_dashboard' ) ); add_filter( 'show_admin_bar', array( $this, 'hide_admin_bar_for_subscribers' ) ); TON_Reg_Registration_Form::register(); TON_Reg_Registration_Handler::register(); + TON_Reg_Profile::register(); add_action( TON_Reg_Mailchimp::CRON_HOOK, array( 'TON_Reg_Mailchimp', 'sync_registration' ) ); + add_action( TON_Reg_Renewal_Reminders::CRON_HOOK, array( 'TON_Reg_Renewal_Reminders', 'run_daily' ) ); add_action( 'update_option_ton_reg_mailchimp_api_key', array( 'TON_Reg_Mailchimp', 'ensure_api_key_no_autoload' ) ); if ( is_admin() ) { @@ -95,22 +99,157 @@ $db_version = '1.3'; } + if ( version_compare( $db_version, '1.4', '<' ) ) { + self::purge_legacy_bonifico_documents(); + $db_version = '1.4'; + } + + if ( version_compare( $db_version, '1.5', '<' ) ) { + TON_Reg_Membership_Cards::create_table(); + if ( false === get_option( TON_Reg_Membership_Card::OPTION_ACTIVE_YEAR ) ) { + add_option( TON_Reg_Membership_Card::OPTION_ACTIVE_YEAR, (int) gmdate( 'Y' ) ); + } + if ( false === get_option( TON_Reg_Membership_Card::OPTION_TEMPLATES ) ) { + add_option( TON_Reg_Membership_Card::OPTION_TEMPLATES, array() ); + } + $db_version = '1.5'; + } + + if ( version_compare( $db_version, '1.6', '<' ) ) { + TON_Reg_Membership_Renewals::create_table(); + TON_Reg_Renewal_Reminders::schedule_cron(); + + $renewal_options = array( + 'ton_reg_email_renewal_first_subject', + 'ton_reg_email_renewal_first_body', + 'ton_reg_email_renewal_second_subject', + 'ton_reg_email_renewal_second_body', + 'ton_reg_email_renewal_admin_subject', + 'ton_reg_email_renewal_admin_body', + 'ton_reg_renewal_reminders_start_year', + ); + $defaults = TON_Reg_Defaults::options(); + foreach ( $renewal_options as $key ) { + if ( false === get_option( $key ) && isset( $defaults[ $key ] ) ) { + add_option( $key, $defaults[ $key ] ); + } + } + + $db_version = '1.6'; + } + update_option( 'ton_reg_db_version', $db_version ); } /** - * Prevent subscribers from accessing wp-admin (except AJAX). + * One-time cleanup after removing «Ricevuta bonifico» upload type. */ - public function block_subscriber_admin() { - if ( ! is_user_logged_in() || wp_doing_ajax() ) { + private static function purge_legacy_bonifico_documents() { + global $wpdb; + + $table = TON_Reg_Database::table_name(); + $ids = $wpdb->get_col( "SELECT id FROM {$table} WHERE doc_bonifico_id IS NOT NULL AND doc_bonifico_id > 0" ); + + if ( ! is_array( $ids ) ) { return; } + foreach ( $ids as $registration_id ) { + TON_Reg_Documents::delete_legacy_bonifico_document( (int) $registration_id ); + } + } + + /** + * @return bool + */ + public static function is_restricted_subscriber() { + if ( ! is_user_logged_in() ) { + return false; + } + $user = wp_get_current_user(); - if ( in_array( 'subscriber', (array) $user->roles, true ) && ! current_user_can( 'manage_options' ) ) { - wp_safe_redirect( home_url() ); + return in_array( 'subscriber', (array) $user->roles, true ) && ! current_user_can( 'manage_options' ); + } + + /** + * Whether a subscriber may access the current wp-admin request. + * + * @return bool + */ + public static function subscriber_can_access_admin() { + global $pagenow; + + if ( 'profile.php' === $pagenow ) { + return true; + } + + if ( 'admin-post.php' === $pagenow && 'ton_reg_download_card' === ( $_REQUEST['action'] ?? '' ) ) { + return true; + } + + return false; + } + + /** + * Prevent subscribers from accessing wp-admin except profile and card download. + */ + public function block_subscriber_admin() { + if ( ! is_user_logged_in() || wp_doing_ajax() || current_user_can( 'manage_options' ) ) { + return; + } + + if ( ! self::is_restricted_subscriber() ) { + return; + } + + if ( self::subscriber_can_access_admin() ) { + return; + } + + global $pagenow; + if ( 'index.php' === $pagenow ) { + wp_safe_redirect( admin_url( 'profile.php' ) ); exit; } + + wp_safe_redirect( home_url() ); + exit; + } + + /** + * Keep only the Profile menu item for subscribers in wp-admin. + */ + public function trim_subscriber_admin_menu() { + if ( ! self::is_restricted_subscriber() ) { + return; + } + + global $menu; + + if ( ! is_array( $menu ) ) { + return; + } + + foreach ( $menu as $item ) { + if ( ! is_array( $item ) || empty( $item[2] ) ) { + continue; + } + if ( 'profile.php' !== $item[2] ) { + remove_menu_page( $item[2] ); + } + } + } + + /** + * Send subscribers to their profile instead of the dashboard. + */ + public function redirect_subscriber_dashboard() { + if ( ! self::is_restricted_subscriber() ) { + return; + } + + wp_safe_redirect( admin_url( 'profile.php' ) ); + exit; } /** diff --git a/includes/class-profile.php b/includes/class-profile.php new file mode 100644 index 0000000..f760f98 --- /dev/null +++ b/includes/class-profile.php @@ -0,0 +1,345 @@ +post_content, 'ton_registration_profile' ) + || false !== strpos( $post->post_content, '[ton_registration_profile' ); + } + + /** + * @return array + */ + private static function status_labels() { + return array( + 'pending' => __( 'In attesa di valutazione', 'ton-italia-registration' ), + 'admitted' => __( 'Ammesso', 'ton-italia-registration' ), + 'rejected' => __( 'Non ammesso', 'ton-italia-registration' ), + ); + } + + /** + * @param WP_User $user WordPress user. + * @param string $context `frontend` or `admin`. + * @return string + */ + public static function render_membership_section( $user, $context = 'frontend' ) { + $row = TON_Reg_Database::get_for_wp_user( $user ); + if ( ! $row ) { + $message = __( 'Nessuna iscrizione collegata al tuo account.', 'ton-italia-registration' ); + if ( 'admin' === $context ) { + return '

' . esc_html( $message ) . '

'; + } + return '

' . esc_html( $message ) . '

'; + } + + $status_labels = self::status_labels(); + $cards = TON_Reg_Membership_Cards::get_by_registration( (int) $row->id ); + + ob_start(); + + if ( 'admin' === $context ) { + self::render_admin_table_open(); + self::render_admin_row( __( 'Nome', 'ton-italia-registration' ), trim( $row->nome . ' ' . $row->cognome ) ); + self::render_admin_row( __( 'Email iscrizione', 'ton-italia-registration' ), $row->email ); + self::render_admin_row( + __( 'Stato iscrizione', 'ton-italia-registration' ), + $status_labels[ $row->status ] ?? $row->status + ); + } else { + echo '
'; + echo '
' . esc_html__( 'Nome', 'ton-italia-registration' ) . '
'; + echo '
' . esc_html( trim( $row->nome . ' ' . $row->cognome ) ) . '
'; + echo '
' . esc_html__( 'Email', 'ton-italia-registration' ) . '
'; + echo '
' . esc_html( $row->email ) . '
'; + echo '
' . esc_html__( 'Stato iscrizione', 'ton-italia-registration' ) . '
'; + echo '
' . esc_html( $status_labels[ $row->status ] ?? $row->status ) . '
'; + echo '
'; + } + + if ( 'admitted' !== $row->status || ! empty( $row->anonymized_at ) ) { + echo self::render_notice( + __( 'La tessera sarà disponibile dopo l\'ammissione.', 'ton-italia-registration' ), + $context + ); + } elseif ( empty( $cards ) ) { + echo self::render_notice( + __( 'Nessuna tessera disponibile al momento.', 'ton-italia-registration' ), + $context + ); + } else { + self::render_cards_list( $cards, $context ); + } + + if ( 'admin' === $context ) { + self::render_admin_table_close(); + } + + return (string) ob_get_clean(); + } + + /** + * @param array $cards Card rows. + * @param string $context Render context. + */ + private static function render_cards_list( $cards, $context ) { + $latest = $cards[0]; + + if ( 'admin' === $context ) { + self::render_admin_row( + sprintf( + /* translators: %d: year */ + __( 'Tessera %d', 'ton-italia-registration' ), + (int) $latest->year + ), + self::render_download_link( (int) $latest->id, __( 'Scarica PDF', 'ton-italia-registration' ) ) + ); + + if ( count( $cards ) > 1 ) { + $history = array(); + foreach ( array_slice( $cards, 1 ) as $card ) { + $history[] = esc_html( (string) $card->year ) . ' — ' . self::render_download_link( + (int) $card->id, + __( 'Scarica', 'ton-italia-registration' ) + ); + } + self::render_admin_row( + __( 'Tessere anni precedenti', 'ton-italia-registration' ), + implode( '
', $history ) + ); + } + return; + } + + echo '
'; + echo '

' . esc_html__( 'Tessera', 'ton-italia-registration' ) . ' ' . esc_html( (string) $latest->year ) . '

'; + echo self::render_download_link( (int) $latest->id, __( 'Scarica tessera', 'ton-italia-registration' ) ); + echo '
'; + + if ( count( $cards ) > 1 ) { + echo '

' . esc_html__( 'Tessere anni precedenti', 'ton-italia-registration' ) . '

'; + echo '
    '; + foreach ( array_slice( $cards, 1 ) as $card ) { + echo '
  • '; + echo esc_html( (string) $card->year ) . ' — '; + echo self::render_download_link( (int) $card->id, __( 'Scarica', 'ton-italia-registration' ) ); + echo '
  • '; + } + echo '
'; + } + } + + /** + * @param string $message Notice text. + * @param string $context Render context. + * @return string + */ + private static function render_notice( $message, $context ) { + if ( 'admin' === $context ) { + self::render_admin_row( '', '

' . esc_html( $message ) . '

' ); + return ''; + } + + return '

' . esc_html( $message ) . '

'; + } + + /** + * Open membership table on profile.php. + */ + private static function render_admin_table_open() { + echo ''; + } + + /** + * Close membership table on profile.php. + */ + private static function render_admin_table_close() { + echo ''; + } + + /** + * @param string $label Row label. + * @param string $value Row value (may contain safe HTML for download links). + */ + private static function render_admin_row( $label, $value ) { + echo ''; + if ( '' !== $label ) { + echo '' . esc_html( $label ) . ''; + } else { + echo '' . esc_html__( 'Avviso', 'ton-italia-registration' ) . ''; + } + echo '' . wp_kses_post( $value ) . ''; + echo ''; + } + + /** + * @param WP_User $user WordPress user. + */ + public static function render_admin_profile_section( $user ) { + if ( ! $user instanceof WP_User || (int) $user->ID !== get_current_user_id() ) { + return; + } + + echo '

' . esc_html__( 'TON ITALIA — Iscrizione e tessera', 'ton-italia-registration' ) . '

'; + echo '

' . esc_html__( 'Qui puoi consultare lo stato della tua iscrizione e scaricare la tessera socio.', 'ton-italia-registration' ) . '

'; + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in render_membership_section(). + echo self::render_membership_section( $user, 'admin' ); + } + + /** + * @param array $atts Shortcode attributes. + * @return string + */ + public static function render_shortcode( $atts ) { + $atts = shortcode_atts( + array( + 'class' => '', + ), + $atts, + 'ton_registration_profile' + ); + + $classes = trim( 'ton-reg-profile ' . $atts['class'] ); + + if ( ! is_user_logged_in() ) { + ob_start(); + echo '
'; + echo '

' . esc_html__( 'Accedi per visualizzare il tuo profilo socio e scaricare la tessera.', 'ton-italia-registration' ) . '

'; + wp_login_form( + array( + 'redirect' => self::profile_url(), + ) + ); + echo '
'; + return (string) ob_get_clean(); + } + + ob_start(); + echo '
'; + echo '

'; + printf( + /* translators: %s: profile URL */ + wp_kses_post( __( 'Il profilo socio è disponibile nel tuo account WordPress: apri il profilo.', 'ton-italia-registration' ) ), + esc_url( self::profile_url() ) + ); + echo '

'; + echo '

' . esc_html__( 'Il mio profilo', 'ton-italia-registration' ) . '

'; + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in render_membership_section(). + echo self::render_membership_section( wp_get_current_user(), 'frontend' ); + echo '
'; + return (string) ob_get_clean(); + } + + /** + * @param int $card_id Card ID. + * @param string $label Link label. + * @return string + */ + private static function render_download_link( $card_id, $label ) { + $url = wp_nonce_url( + add_query_arg( + array( + 'action' => 'ton_reg_download_card', + 'card_id' => (int) $card_id, + ), + admin_url( 'admin-post.php' ) + ), + 'ton_reg_download_card_' . (int) $card_id, + 'ton_reg_card_nonce' + ); + + return '' . esc_html( $label ) . ''; + } + + /** + * @return void + */ + public static function handle_download() { + $card_id = isset( $_GET['card_id'] ) ? (int) $_GET['card_id'] : 0; + check_admin_referer( 'ton_reg_download_card_' . $card_id, 'ton_reg_card_nonce' ); + TON_Reg_Membership_Card::stream_download( $card_id ); + } + + /** + * @return void + */ + public static function handle_download_login_required() { + wp_safe_redirect( wp_login_url( wp_get_referer() ?: home_url() ) ); + exit; + } +} diff --git a/includes/class-registration-form.php b/includes/class-registration-form.php index 5620a11..639b6df 100644 --- a/includes/class-registration-form.php +++ b/includes/class-registration-form.php @@ -13,31 +13,61 @@ class TON_Reg_Registration_Form { /** + * Whether the registration form was rendered on the current request. + * + * @var bool + */ + private static $form_rendered = false; + + /** * Register hooks. */ public static function register() { add_shortcode( 'ton_registration_form', array( __CLASS__, 'render_shortcode' ) ); + add_shortcode( 'ton_registration_success', array( __CLASS__, 'render_success_shortcode' ) ); add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) ); } /** - * Enqueue form assets. + * Enqueue form assets when the page content includes the shortcode. */ public static function enqueue_assets() { - if ( ! self::page_has_shortcode() ) { + if ( self::page_has_shortcode() ) { + self::enqueue_form_assets(); + } + } + + /** + * Ensure assets are loaded whenever the form shortcode is actually rendered. + */ + public static function ensure_form_assets() { + self::enqueue_form_assets(); + } + + /** + * @return void + */ + private static function enqueue_form_assets() { + static $enqueued = false; + if ( $enqueued ) { return; } + $enqueued = true; + + $form_css_path = TON_REG_PLUGIN_DIR . 'assets/css/form.css'; + $form_js_path = TON_REG_PLUGIN_DIR . 'assets/js/form.js'; + wp_enqueue_style( 'ton-reg-form', TON_REG_PLUGIN_URL . 'assets/css/form.css', array(), - TON_REG_VERSION + file_exists( $form_css_path ) ? (string) filemtime( $form_css_path ) : TON_REG_VERSION ); wp_enqueue_script( 'ton-reg-form', TON_REG_PLUGIN_URL . 'assets/js/form.js', array(), - TON_REG_VERSION, + file_exists( $form_js_path ) ? (string) filemtime( $form_js_path ) : TON_REG_VERSION, true ); @@ -51,6 +81,7 @@ 'msgCap' => __( 'Il CAP deve essere di 5 cifre.', 'ton-italia-registration' ), 'msgProvincia' => __( 'Inserisci la sigla provincia (2 lettere).', 'ton-italia-registration' ), 'msgDate' => __( 'Inserisci la data nel formato gg-mm-aaaa.', 'ton-italia-registration' ), + 'msgDateFuture' => __( 'La data di dichiarazione non può essere futura.', 'ton-italia-registration' ), 'msgCheckbox' => __( 'Devi selezionare questa casella per continuare.', 'ton-italia-registration' ), 'msgCaptcha' => __( 'Inserisci la risposta alla verifica anti-spam.', 'ton-italia-registration' ), ) @@ -58,11 +89,205 @@ } /** + * Mark form as rendered and attach footer fallback script. + * + * @return void + */ + private static function mark_form_rendered() { + if ( self::$form_rendered ) { + return; + } + + self::$form_rendered = true; + self::ensure_form_assets(); + add_action( 'wp_footer', array( __CLASS__, 'print_form_field_normalization_script' ), 20 ); + } + + /** + * Inline fallback so field normalization works even if external JS is blocked or cached stale. + * + * @return void + */ + public static function print_form_field_normalization_script() { + if ( ! self::$form_rendered ) { + return; + } + ?> + + post_content, 'ton_registration_form' ); + if ( $post instanceof WP_Post ) { + if ( self::content_has_shortcode( $post->post_content, 'ton_registration_form' ) ) { + return true; + } + if ( self::content_has_shortcode( $post->post_content, 'ton_registration_success' ) ) { + return true; + } + } + + return isset( $_GET['ton_reg'] ) && in_array( $_GET['ton_reg'], array( 'success', 'error' ), true ); + } + + /** + * @param string $content Post content. + * @param string $tag Shortcode tag. + * @return bool + */ + private static function content_has_shortcode( $content, $tag ) { + if ( ! is_string( $content ) || '' === $content ) { + return false; + } + + if ( has_shortcode( $content, $tag ) ) { + return true; + } + + if ( false !== strpos( $content, '[' . $tag ) ) { + return true; + } + + if ( function_exists( 'parse_blocks' ) && false !== strpos( $content, '