# CityMag

Site de petites annonces + annuaire + agenda + magazine, avec comptes membres,
messagerie, espace professionnel, modération et paiements Stripe.

## Démarrer

```bash
composer install
cp .env.example .env && php artisan key:generate
php artisan migrate --seed
php artisan storage:link
php artisan serve
```

En production, Apache sert `public/`. Aucun build Node : le CSS et le JS sont écrits à la main
dans `public/assets/`.

Un seul cron suffit :

```
* * * * * php artisan schedule:run >> /dev/null 2>&1
```

Il déclenche l'expiration des annonces (`citymag:expirer-annonces`) et l'envoi des alertes
(`citymag:envoyer-alertes`).

`QUEUE_CONNECTION=sync` par défaut : les e-mails partent dans la requête, le site fonctionne
sans worker. Sous forte charge, basculer sur `database` et lancer `php artisan queue:work`
en service — sans quoi plus aucun e-mail ne partirait.

Le tableau de bord d'administration affiche un panneau **Configuration** listant ce qui reste
à renseigner (SMTP, clés Stripe, identité de l'éditeur, réseaux sociaux) : le site dit lui-même
ce qui n'est pas branché plutôt que de le laisser croire.

## Valider un compte à la main

Tant qu'aucun SMTP n'est configuré, l'e-mail de vérification part dans les journaux et le membre
reste bloqué avant le dépôt d'annonce. Pour débloquer un compte :

```bash
php artisan citymag:verifier-email contact@exemple.fr
```

L'option `--role=pro` (ou `admin`) promeut le compte au passage.

## Comptes de démonstration

| Rôle | Identifiant | Mot de passe |
|---|---|---|
| Administrateur | `admin@citymag.fr` | `password` |
| Professionnel | `pro@citymag.fr` | `password` |
| Particulier | `membre@citymag.fr` | `password` |

## Arborescence des catégories

`config/categories.php` — source unique de vérité (850 entrées).

```
rubriques : annonces · annuaire · agenda · magazine
univers   : 72 (Véhicule, Immobilier, Restauration, Brasserie, …)
niveaux   : univers → catégorie → sous-catégorie
```

Un enfant s'écrit soit en feuille (`'Voiture'`), soit en nœud (`'Location' => ['Appartement', …]`).
Ajouter une catégorie = ajouter une ligne : slugs, URLs, menus, filtres et fil d'Ariane suivent.

`App\Support\Taxonomy` expose `tree()`, `find()`, `breadcrumb()`, `flat()`, `search()`, `json()`.
`config/villes.php` fait de même pour les communes.

## Modèle de données

| Table | Rôle |
|---|---|
| `users` | membres, rôle `particulier`/`pro`/`admin`, colonnes Stripe (Cashier) |
| `ads` | petites annonces (statut, prix en centimes, options de visibilité) |
| `businesses` | fiches de l'annuaire (horaires, services, note, vérification) |
| `events` · `articles` | agenda et magazine |
| `photos` | images polymorphes (annonces, fiches, événements) |
| `favorites` · `alerts` | favoris polymorphes et alertes e-mail |
| `conversations` · `messages` | messagerie entre membres |
| `reviews` · `reports` · `contact_messages` | avis, signalements, contact |
| `orders` | achats d'options à l'unité (Stripe Checkout) |
| `subscriptions` · `subscription_items` | abonnements pro (Cashier) |

Le rattachement à l'arborescence se fait par `category_path` (`immobilier/vente/appartement`).
Le trait `App\Models\Concerns\HasCategory` en dérive `category`, `trail`, `univers` et `icon`,
et le scope `inCategory()` filtre une branche entière. `HasReference` produit les références
publiques stables : `A100037`, `P200041`, `E300029`, `M400023`.

`App\Support\Content` est le point d'entrée du contenu publié (`query()`, `annonces()`, `pros()`,
`evenements()`, `articles()`, `counts()`, `stats()`). Les vues Blade consomment les modèles
comme des tableaux — `$ad['price_label']` fonctionne grâce aux accesseurs.

`App\Support\Demo` ne sert plus qu'à alimenter les seeders.

## Adresses et pays

`config/pays.php` liste les pays acceptés : code ISO, nom, motif de code postal et exemple.
`App\Support\Pays` en tire la validation du code postal, le libellé affiché et — surtout — la
règle qui suit.

Le **département n'est jamais saisi** : il se déduit du code postal, et uniquement en France.
Le trait `App\Models\Concerns\HasLocalisation` (annonces, fiches, événements, membres) le
recalcule à chaque enregistrement, quel que soit le point d'entrée. Une adresse étrangère
enregistre donc `dept` à null et sort des filtres départementaux, au lieu d'y apparaître par
erreur : 08005 Barcelone n'est pas les Ardennes.

Il expose aussi `localisation` (« Lille (59) » en France, « Barcelona (Espagne) » ailleurs),
`pays_nom`, `etranger` et le scope `pays()`. Le sélecteur de pays du formulaire est le composant
`<x-pays-select>` ; celui des filtres n'apparaît que si le contenu déborde des frontières
(`Content::paysPresents()`).

Ajouter un pays = ajouter une ligne dans `config/pays.php`.

## Paiements (Stripe)

`config/billing.php` est la source unique des offres, lue par `/tarifs`, `/pro` et le tunnel.

**Trois offres.**

| Offre | Prix | Engagement |
|---|---|---|
| Starter | 15,00 € / mois | 6 mois minimum |
| Premium | 120,00 € / an | aucun |
| Particulier | gratuit — 5,00 € par annonce **immobilière** | aucun |

**Abonnements** — souscription directe en Checkout, sans période d'essai ni mise en relation
commerciale. Cashier gère le cycle de vie ; `SubscriptionController` couvre souscription,
changement de formule au prorata, résiliation, reprise et portail de facturation.
L'engagement de Starter (`plans.starter.engagement_mois`) est compté depuis la création de
l'abonnement par `User::engagementJusquAu()` : tant qu'il court, résiliation **et** changement
de formule sont refusés — sans quoi un passage sur Premium l'annulerait.
Les avantages réservés à une formule se déclarent dans la config et se lisent avec
`User::planAutorise()` (ex. `evenements`, propre à Premium).

**Offre Particulier** — le dépôt est gratuit sur tout CityMag, à l'exception des univers listés
dans `billing.particulier.univers` (aujourd'hui `immobilier`), facturés 5,00 € l'annonce.
`App\Support\Tarif` centralise la règle ; un abonnement Pro actif — ou le rôle admin — en
dispense. `AdController::store` enregistre alors l'annonce en `pending` et redirige vers
`GET /annonce/{ad}/publication`, qui ouvre le Checkout. L'annonce n'est publiée qu'au paiement
confirmé (`Boost` applique l'effet `publish`), et « Remettre en ligne » y renvoie plutôt que de
contourner les frais.

**Options à l'unité** — Remontée en tête 2,90 €, À la une 7 jours 9,90 €, Badge Urgent 1,90 €,
Pack Vente rapide 14,90 €. Achat en Checkout depuis la fiche annonce ; `App\Support\Boost`
applique les effets (`bumped_at`, `featured_until`, `urgent_until`) de façon idempotente,
et prolonge au lieu d'écraser si une période est déjà active.

**Webhook** — `POST /stripe/webhook`, exempté de CSRF. `Billing\WebhookController` étend celui
de Cashier et ajoute `checkout.session.completed` (confirmation des options, bascule du compte
en `pro`) et `checkout.session.expired`.

Configuration :

```
STRIPE_KEY=pk_…
STRIPE_SECRET=sk_…
STRIPE_WEBHOOK_SECRET=whsec_…
STRIPE_PRICE_STARTER=price_…     # 15,00 € / mois
STRIPE_PRICE_PREMIUM=price_…     # 120,00 € / an
```

Sans ces clés, le site reste entièrement fonctionnel : le tunnel affiche un message explicite
au lieu d'échouer côté Stripe.

## URLs

| Motif | Exemple |
|---|---|
| `/{rubrique}/{univers?}/{categorie?}/{sous?}` | `/annonces/immobilier/vente/appartement` |
| `/annonce/{ref}-{slug}` · `/professionnel/…` · `/evenement/…` · `/article/…` | fiches détail |
| `/connexion` `/inscription` `/mot-de-passe-oublie` `/email/verification` | authentification |
| `/deposer` · `/compte/annonces/{ad}/modifier` | dépôt et édition d'annonce |
| `/compte` `/compte/annonces` `/compte/favoris` `/compte/alertes` `/compte/messages` `/compte/avis` `/compte/achats` `/compte/profil` | espace membre |
| `/pro/fiche` `/pro/evenements` | espace professionnel |
| `/admin` `/admin/annonces` `/admin/pros` `/admin/signalements` `/admin/contacts` `/admin/membres` | modération |
| `/abonnement/{plan}` `/annonce/{ad}/option/{option}` `/annonce/{ad}/publication` `/stripe/webhook` | paiements |
| `/data/categories.json` · `/data/arborescence.json` | autocomplétion et selects en cascade |
| `/sitemap.xml` | plan du site (pages fixes, 850 catégories, contenus publiés) |

## Recherche

Un même terme est cherché à quatre niveaux, dans chacune des quatre rubriques :

1. **le texte** — titre, description, lieu, organisateur, auteur selon la rubrique ;
2. **la référence** exacte (`A100037`) ;
3. **la commune** — nom même partiel (« Lil ») ou code postal (« 59000 ») ;
4. **la catégorie** — « concert » remonte tout l'univers Concert / Festival, même si
   aucun titre ne contient le mot.

Le rapprochement des catégories vit dans `App\Support\Taxonomy` :

| Règle | Exemple | Note |
|---|---|---|
| le nom commence par le terme | `brass` → Brasserie | 0 |
| le nom contient le terme | `bock` → Sous-bock | 1 |
| le terme contient le nom | `pompe à bière` → Pompe | 2 |
| racine commune ≥ 6 lettres | `restaurant` → Restauration | 3 |

Casse et accents sont ignorés des deux côtés (`Str::ascii` côté PHP, collation
`utf8mb4_unicode_ci` côté base). `config/synonymes.php` complète ce que la racine
commune ne peut pas relier — `brocante` → Braderie / Vide-grenier, `resto`,
`boulot`, `auto`… : une ligne à ajouter suffit.

Chaque filtre forme son propre groupe `(… or …)` combiné en `and` : restreindre à
une catégorie puis chercher un terme d'une autre catégorie ne renvoie rien, comme
attendu.

**Points d'entrée** : barre du bandeau, tiroir mobile et page `/recherche` (les quatre
rubriques d'un coup) ; formulaire d'accueil par rubrique — son sélecteur de catégorie
redirige vers l'URL canonique en segments (`?univers=vehicule` → `/annonces/vehicule`) ;
colonne de filtres des listings ; autocomplétion sur les 850 catégories ; filtres en
place sur `/categories`, `/villes` et `/aide` ; recherches du back-office (annonces,
professionnels, membres) et de l'espace membre.

## Pages légales et identité

Les quatre documents (`/cgu`, `/confidentialite`, `/cookies`, `/mentions-legales`) ont chacun
leur contenu propre, rédigé dans `PageController::documentsLegaux()`. L'identité de l'éditeur
vient de `config/citymag.php`, alimentée par le `.env` : raison sociale, SIREN, TVA, adresse,
directeur de la publication, hébergeur, contact DPO. Renseigner ces variables met à jour les
mentions partout à la fois.

> Ces textes sont conformes aux usages mais n'ont pas été relus par un juriste.
> Faites-les valider avant l'ouverture au public.

`config/reseaux.php` gère les liens sociaux : une URL vide masque l'icône, jamais de lien mort.

## Cartographie

`App\Support\Carte` produit les liens de localisation et d'itinéraire vers OpenStreetMap.
Aucune clé d'API, aucun script tiers, donc aucun traceur ajouté aux pages publiques.
Le pays vient du contenu : une adresse de Barcelone est cherchée en Espagne, pas en France.
`config/villes.php` ne couvrant que les Hauts-de-France, une commune étrangère affiche le
repère décoratif et le lien vers la carte, sans iframe.

## Règles métier

- Dépôt gratuit, **10 annonces actives** maximum sans abonnement (`User::QUOTA_PARTICULIER`).
- Seule exception au dépôt gratuit : **5,00 € par annonce immobilière** pour un particulier,
  réglés au dépôt ; l'annonce reste `pending` jusqu'à la confirmation du paiement.
- Publication soumise à la **vérification de l'adresse e-mail**.
- Annonces en ligne **60 jours** (`Ad::DUREE_JOURS`), prolongeables depuis le compte.
- Les événements de l'agenda sont réservés à la formule **Premium** (`planAutorise('evenements')`).
- **Starter** est souscrit pour 6 mois minimum : ni résiliation ni changement de formule avant le terme.
- Un avis par membre et par établissement ; la note moyenne est recalculée à chaque écriture.
- Favoris en base pour les membres, `localStorage` pour les visiteurs, repris à la connexion.
- Le département découle du code postal **français** ; hors de France il reste vide.
- Les services d'une fiche viennent de `config/services_pro.php` : un libellé hors liste est
  refusé, car le formulaire n'enverrait pas la case correspondante à l'enregistrement suivant.

## Design system

`public/assets/css/citymag.css` — CSS maison, variables en tête de fichier.

```
--orange #F07C22   (logo)      --ink #414042 (anthracite)
--orange-600/300/100/50        --line #E7E7EA   --surface-2 #F7F7F8
```

Composants Blade : `<x-logo>`, `<x-icon name="…">` (≈90 icônes SVG inline),
`<x-media>` (photo réelle ou emplacement), `<x-ad-card>`, `<x-pro-card>`, `<x-event-card>`,
`<x-article-card>`.

`public/assets/js/citymag.js` — vanilla, sans dépendance : méga-menu, tiroir mobile,
autocomplétion, onglets, filtres, favoris, galerie, dépôt en 4 étapes, aperçu des photos,
menu utilisateur, compteurs.

## Tests

```bash
php artisan test
```

104 tests couvrent la navigation publique, l'authentification, le dépôt d'annonce et ses quotas,
les interactions (favoris, messagerie, avis, signalements, alertes), la recherche
(catégories, dérivés, synonymes, code postal, combinaison des filtres, back-office), la
facturation (souscription directe, application et idempotence des options, webhook), les
adresses étrangères (dérivation du département, validation du code postal par pays, filtres,
itinéraires, référentiel des services), les pages légales, le plan du site et l'absence de lien
mort sur les pages publiques.

L'extension `pdo_sqlite` étant absente de ce serveur, la suite tourne sur une base MariaDB
dédiée `citymag_test`, déclarée dans `phpunit.xml`.
