Initial commit — Kankwa platform (Phases 1–9)
Stack : FastAPI + PostgreSQL + React 18 + Stripe + FlareSolverr. Services : Kdo, Hub, Kontrib, Kount, Kal, Kwiz + Premium (Stripe Embedded Checkout). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
31
.env.example
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# PostgreSQL
|
||||
POSTGRES_USER=platform
|
||||
POSTGRES_PASSWORD=changeme
|
||||
POSTGRES_DB=platform
|
||||
DATABASE_URL=postgresql+asyncpg://platform:changeme@kankwa-db:5432/platform
|
||||
|
||||
# JWT
|
||||
SECRET_KEY=changeme_long_random_string_min_32_chars
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
MAGIC_LINK_EXPIRE_MINUTES=15
|
||||
CO_OWNER_INVITE_EXPIRE_MINUTES=10080
|
||||
|
||||
# Brevo (emails)
|
||||
BREVO_API_KEY=xkeysib-xxxx
|
||||
FROM_EMAIL=noreply@tondomaine.com
|
||||
FROM_NAME=Kankwa
|
||||
|
||||
# Stripe (Phase 9 — abonnement Premium)
|
||||
STRIPE_SECRET_KEY=sk_live_xxxx
|
||||
STRIPE_PUBLISHABLE_KEY=pk_live_xxxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxxx
|
||||
STRIPE_PRICE_MONTHLY=price_xxxx # 1,99 €/mois
|
||||
STRIPE_PRICE_ANNUAL=price_xxxx # 19,99 €/an
|
||||
|
||||
# App
|
||||
FRONTEND_URL=https://tondomaine.com
|
||||
ENVIRONMENT=production
|
||||
|
||||
# Freemium
|
||||
MAX_FREE_ACTIVE_SERVICES=3
|
||||
22
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
|
||||
certbot/
|
||||
*.log
|
||||
|
||||
pgdata/
|
||||
|
||||
.claude/
|
||||
82
CLAUDE.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Kankwa — Contexte projet
|
||||
|
||||
Plateforme de services du quotidien (wishlist, sondages, Secret Santa, partage de frais...).
|
||||
Alternatives éthiques aux outils grand public, hébergées sur serveur privé, RGPD-friendly.
|
||||
Modèle : abonnement Premium + freemium (3 services actifs max).
|
||||
|
||||
Contexte complet : `/home/miaw/.claude/projects/-home-miaw-kankwa/memory/PLATFORM_CONCEPT.md`
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Couche | Techno |
|
||||
|---|---|
|
||||
| Backend | Python 3.12 + FastAPI (async) |
|
||||
| BDD | PostgreSQL 16 + SQLAlchemy 2.0 async + Alembic |
|
||||
| Auth | JWT + bcrypt + magic link |
|
||||
| Emails | SMTP via `aiosmtplib` (Infomaniak par défaut, cf. `shared/email/client.py`) |
|
||||
| Paiements | Stripe |
|
||||
| Frontend | React 18 + Vite + Tailwind CSS + React Router v6 |
|
||||
| Scraping | FlareSolverr (bypass Cloudflare + rendu JS) + extruct + price-parser |
|
||||
| Reverse proxy | Caddy (externe, container sur ai-net) |
|
||||
| Conteneurs | Docker + Docker Compose |
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure Docker
|
||||
|
||||
- Réseau : `ai-net` (externe, déjà existant)
|
||||
- 4 conteneurs avec `container_name` fixe :
|
||||
- `kankwa-db` — PostgreSQL
|
||||
- `kankwa-api` — FastAPI sur `127.0.0.1:8000`
|
||||
- `kankwa-frontend` — React servi par `serve` sur `127.0.0.1:3001`
|
||||
- `kankwa-scraper` — FlareSolverr sur port interne 8191 (non exposé)
|
||||
- Caddy route : `kankwa-frontend:3001` et `kankwa-api:8000`
|
||||
- Tout en prod directement — pas de mode dev, pas de hot-reload
|
||||
|
||||
---
|
||||
|
||||
## Architecture monorepo
|
||||
|
||||
```
|
||||
kankwa/
|
||||
├── api/ ← FastAPI app (main.py, config.py, routers/)
|
||||
├── models/ ← Modèles SQLAlchemy (un fichier par entité)
|
||||
├── services/ ← Logique métier par service (wishlist/, poll/, etc.)
|
||||
├── shared/ ← Briques communes (auth/, database/, email/, payments/, rate_limit/)
|
||||
├── alembic/ ← Toutes les migrations BDD
|
||||
├── frontend/ ← React app (src/shared/, src/services/)
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
**Règle clé** : chaque brique dans `shared/` est codée une fois, jamais dupliquée entre services.
|
||||
|
||||
---
|
||||
|
||||
## Phases de développement
|
||||
|
||||
- **Phase 1 — Fondations** ✅ : shared/, models/user.py, auth, email, features génériques (QR code, commentaires, co-owner)
|
||||
- **Phase 2 — Kdo** ✅ : premier service MVP, générateur de revenus affiliation, scraping URL (FlareSolverr), image produit
|
||||
- **Phase 3 — Hub** ✅ : hub central (projets/events), RSVP retiré
|
||||
- **Phase 4 — Kontrib** ✅ : différenciateur #1 vs Tilune
|
||||
- **Phase 5 — Kount** ✅ : différenciateur #2, capter fuyards Splitwise
|
||||
- **Phase 6 — Kast** ⏳ : Secret Santa, prêt pour Noël
|
||||
- **Phase 7 — Kwiz** ✅ : sondages, compléter le hub
|
||||
- **Phase 8 — Kal** ✅ : disponibilités, compléter le hub
|
||||
- **Phase 9 — Premium** ✅ : Stripe Checkout + Customer Portal, abonnement mensuel/annuel
|
||||
- **Phase 10 — LLM** : Ollama local, zéro coût marginal, données jamais partagées
|
||||
|
||||
**Ne jamais sauter une phase.** Valider chaque phase avant de passer à la suivante.
|
||||
|
||||
---
|
||||
|
||||
## Patterns transverses
|
||||
|
||||
- **share_token** : chaque ressource partageable a un UUID distinct de son `id` pour les URLs publiques
|
||||
- **event_id nullable** : toutes les ressources peuvent être rattachées à un Hub (NULL = mode autonome)
|
||||
- **Ownership** : toute mutation vérifie owner (`resource.user_id == current_user.id`) OU co-owner (`has_resource_access`), retourne 403 sinon. Les `get_owned_*` de chaque `service.py` délèguent à `get_owned_resource()` (`shared/auth/co_owner.py`) qui encapsule les deux checks.
|
||||
- **Suppression de ressource** : toujours appeler `cleanup_resource_ownership(type, id, db)` (co-owners + invitations n'ont pas de FK, donc pas de CASCADE BDD).
|
||||
- **Annulation invité** : lookup par token factorisé via `get_by_token_or_404()` (`shared/db_helpers.py`) ; l'email/label reste propre à chaque service.
|
||||
- **Freemium** : limite `MAX_FREE_ACTIVE_SERVICES` (=3) sur les services *facturés* — **source unique** dans `shared/auth/premium_check.py` (`enforce_premium_limit(user, db)`). Comptés : Kdo + Kontrib + Kount + Kal + Kwiz. **Hub illimité** (inutile seul, agrège d'autres services). `/me/usage` doit refléter exactement ce set (`counted=True`).
|
||||
- **Premium** : abonnement payant (`is_premium`/`premium_until` sur User) lève la limite. Phase 9 ✅ — Stripe **Embedded Checkout (modale in-app, `ui_mode=embedded` + `redirect_on_completion=never`) + Customer Portal** (redirect). Routes `/api/billing/{config,checkout,portal,webhook}` (`api/routers/billing.py`), client `shared/payments/stripe_client.py`. `/checkout` renvoie un `client_secret` ; front : `PremiumCheckoutModal.tsx` (`@stripe/react-stripe-js`), `onComplete` → polling `is_premium`. `is_premium` modifié **uniquement** par le webhook signé. Prix : 1,99 €/mois + 19,99 €/an. `is_premium_active(user)` (premium_check.py) = flag ET `premium_until` non expiré.
|
||||
8
Caddyfile.example
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
tondomaine.com {
|
||||
handle /api/* {
|
||||
reverse_proxy localhost:8000
|
||||
}
|
||||
handle {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
}
|
||||
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
41
alembic.ini
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
51
alembic/env.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from api.config import settings
|
||||
from shared.database.base_model import Base
|
||||
import models # noqa: F401 — enregistre tous les modèles
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online() -> None:
|
||||
engine = create_async_engine(settings.database_url)
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
25
alembic/script.py.mako
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
80
alembic/versions/0001_initial.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-05-19
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("password_hash", sa.String(255), nullable=True),
|
||||
sa.Column("is_premium", sa.Boolean, nullable=False, server_default="false"),
|
||||
sa.Column("premium_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("stripe_customer_id", sa.String(255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("email"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"magic_links",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("email", sa.String(255), nullable=False, index=True),
|
||||
sa.Column("token", UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), unique=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"lists",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("event_id", UUID(as_uuid=True), nullable=True), # FK vers events.id — Phase 4
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("occasion", sa.String(50), nullable=True),
|
||||
sa.Column("event_date", sa.Date, nullable=True),
|
||||
sa.Column("share_token", UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), unique=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"gifts",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("list_id", UUID(as_uuid=True), sa.ForeignKey("lists.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("price", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("url", sa.Text, nullable=True),
|
||||
sa.Column("position", sa.Integer, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"reservations",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("gift_id", UUID(as_uuid=True), sa.ForeignKey("gifts.id", ondelete="CASCADE"), nullable=False, unique=True),
|
||||
sa.Column("participant_name", sa.String(100), nullable=False),
|
||||
sa.Column("participant_email", sa.String(255), nullable=False),
|
||||
sa.Column("cancel_token", UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), unique=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("reservations")
|
||||
op.drop_table("gifts")
|
||||
op.drop_table("lists")
|
||||
op.drop_table("magic_links")
|
||||
op.drop_table("users")
|
||||
58
alembic/versions/0002_generic_features.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""generic features: comments, ownership
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-05-20
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"comments",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("resource_type", sa.String(50), nullable=False),
|
||||
sa.Column("resource_id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("author_name", sa.String(100), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_comments_resource", "comments", ["resource_type", "resource_id"])
|
||||
|
||||
op.create_table(
|
||||
"ownership_invitations",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("resource_type", sa.String(50), nullable=False),
|
||||
sa.Column("resource_id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("token", UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), unique=True),
|
||||
sa.Column("invited_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"resource_co_owners",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("resource_type", sa.String(50), nullable=False),
|
||||
sa.Column("resource_id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("resource_type", "resource_id", "user_id", name="uq_resource_co_owner"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("resource_co_owners")
|
||||
op.drop_table("ownership_invitations")
|
||||
op.drop_index("ix_comments_resource", table_name="comments")
|
||||
op.drop_table("comments")
|
||||
38
alembic/versions/0003_gift_features.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""gift: is_priority, participation_mode, contributions
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-05-21
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("gifts", sa.Column("is_priority", sa.Boolean(), nullable=False, server_default="false"))
|
||||
op.add_column("gifts", sa.Column("participation_mode", sa.Boolean(), nullable=False, server_default="false"))
|
||||
|
||||
op.create_table(
|
||||
"contributions",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("gift_id", UUID(as_uuid=True), sa.ForeignKey("gifts.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("participant_name", sa.String(100), nullable=False),
|
||||
sa.Column("participant_email", sa.String(255), nullable=False),
|
||||
sa.Column("amount", sa.Numeric(10, 2), nullable=False),
|
||||
sa.Column("cancel_token", UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), unique=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_contributions_gift_id", "contributions", ["gift_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_contributions_gift_id", table_name="contributions")
|
||||
op.drop_table("contributions")
|
||||
op.drop_column("gifts", "participation_mode")
|
||||
op.drop_column("gifts", "is_priority")
|
||||
25
alembic/versions/0004_reservation_email_optional.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""reservation email optional
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-05-23
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0004'
|
||||
down_revision = '0003'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column('reservations', 'participant_email', nullable=True)
|
||||
op.alter_column('contributions', 'participant_email', nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("UPDATE reservations SET participant_email = '' WHERE participant_email IS NULL")
|
||||
op.alter_column('reservations', 'participant_email', nullable=False)
|
||||
op.execute("UPDATE contributions SET participant_email = '' WHERE participant_email IS NULL")
|
||||
op.alter_column('contributions', 'participant_email', nullable=False)
|
||||
70
alembic/versions/0005_events.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""events
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-05-23
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = '0005'
|
||||
down_revision = '0004'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'events',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('event_type', sa.String(50), nullable=True),
|
||||
sa.Column('event_date', sa.Date, nullable=True),
|
||||
sa.Column('location', sa.String(255), nullable=True),
|
||||
sa.Column('description', sa.Text, nullable=True),
|
||||
sa.Column('share_token', UUID(as_uuid=True), unique=True, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_events_user_id', 'events', ['user_id'])
|
||||
|
||||
op.create_table(
|
||||
'event_rsvps',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('event_id', UUID(as_uuid=True), sa.ForeignKey('events.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('participant_name', sa.String(100), nullable=False),
|
||||
sa.Column('participant_email', sa.String(255), nullable=True),
|
||||
sa.Column('status', sa.String(20), nullable=False),
|
||||
sa.Column('cancel_token', UUID(as_uuid=True), unique=True, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_event_rsvps_event_id', 'event_rsvps', ['event_id'])
|
||||
|
||||
op.create_table(
|
||||
'event_services',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('event_id', UUID(as_uuid=True), sa.ForeignKey('events.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('service_type', sa.String(50), nullable=False),
|
||||
sa.Column('resource_id', UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('is_visible', sa.Boolean, default=True, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_event_services_event_id', 'event_services', ['event_id'])
|
||||
|
||||
op.create_table(
|
||||
'event_notifications',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('event_id', UUID(as_uuid=True), sa.ForeignKey('events.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('type', sa.String(50), nullable=False),
|
||||
sa.Column('content', sa.Text, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_event_notifications_event_id', 'event_notifications', ['event_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('event_notifications')
|
||||
op.drop_table('event_services')
|
||||
op.drop_table('event_rsvps')
|
||||
op.drop_table('events')
|
||||
23
alembic/versions/0006_remove_list_occasion_date.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""remove occasion and event_date from lists
|
||||
|
||||
Revision ID: 0006
|
||||
Revises: 0005
|
||||
Create Date: 2026-05-24
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0006'
|
||||
down_revision = '0005'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column('lists', 'occasion')
|
||||
op.drop_column('lists', 'event_date')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column('lists', sa.Column('event_date', sa.Date(), nullable=True))
|
||||
op.add_column('lists', sa.Column('occasion', sa.String(50), nullable=True))
|
||||
21
alembic/versions/0007_list_description.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""add description to lists
|
||||
|
||||
Revision ID: 0007
|
||||
Revises: 0006
|
||||
Create Date: 2026-05-24
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0007'
|
||||
down_revision = '0006'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('lists', sa.Column('description', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('lists', 'description')
|
||||
21
alembic/versions/0008_reservation_reveal_to_owner.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""add reveal_to_owner to reservations
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-05-26
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0008'
|
||||
down_revision = '0007'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('reservations', sa.Column('reveal_to_owner', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('reservations', 'reveal_to_owner')
|
||||
23
alembic/versions/0009_email_verification.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""add email_verified to users
|
||||
|
||||
Revision ID: 0009
|
||||
Revises: 0008
|
||||
Create Date: 2026-05-26
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0009'
|
||||
down_revision = '0008'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('users', sa.Column('email_verified', sa.Boolean(), nullable=False, server_default='false'))
|
||||
# Existing accounts are considered verified
|
||||
op.execute("UPDATE users SET email_verified = true")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('users', 'email_verified')
|
||||
30
alembic/versions/0010_email_verifications_table.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""create email_verifications table
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
Create Date: 2026-05-26
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = '0010'
|
||||
down_revision = '0009'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'email_verifications',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('email', sa.String(255), nullable=False, index=True),
|
||||
sa.Column('code', sa.String(6), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('email_verifications')
|
||||
69
alembic/versions/0011_bring.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""create bring tables
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
Create Date: 2026-05-26
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = '0011'
|
||||
down_revision = '0010'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'bring_lists',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('event_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text, nullable=True),
|
||||
sa.Column('share_token', postgresql.UUID(as_uuid=True), unique=True, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'bring_items',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('bring_list_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('bring_lists.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text, nullable=True),
|
||||
sa.Column('quantity', sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column('unit', sa.String(30), nullable=True),
|
||||
sa.Column('position', sa.Integer, default=0),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'bring_declarations',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('item_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('bring_items.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('participant_name', sa.String(100), nullable=False),
|
||||
sa.Column('quantity_declared', sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column('cancel_token', postgresql.UUID(as_uuid=True), unique=True, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'bring_suggestions',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('bring_list_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('bring_lists.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('participant_name', sa.String(100), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text, nullable=True),
|
||||
sa.Column('quantity', sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column('unit', sa.String(30), nullable=True),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('bring_suggestions')
|
||||
op.drop_table('bring_declarations')
|
||||
op.drop_table('bring_items')
|
||||
op.drop_table('bring_lists')
|
||||
23
alembic/versions/0012_bring_auto_validate.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""add auto_validate_suggestions to bring_lists
|
||||
|
||||
Revision ID: 0012
|
||||
Revises: 0011
|
||||
Create Date: 2026-05-26
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0012'
|
||||
down_revision = '0011'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('bring_lists', sa.Column(
|
||||
'auto_validate_suggestions', sa.Boolean, nullable=False, server_default='false'
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('bring_lists', 'auto_validate_suggestions')
|
||||
36
alembic/versions/0013_user_id_on_contributions.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""add user_id to reservations, contributions, bring_declarations
|
||||
|
||||
Revision ID: 0013
|
||||
Revises: 0012
|
||||
Create Date: 2026-05-27
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = '0013'
|
||||
down_revision = '0012'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('reservations', sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key('fk_reservations_user_id', 'reservations', 'users', ['user_id'], ['id'], ondelete='SET NULL')
|
||||
|
||||
op.add_column('contributions', sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key('fk_contributions_user_id', 'contributions', 'users', ['user_id'], ['id'], ondelete='SET NULL')
|
||||
|
||||
op.add_column('bring_declarations', sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key('fk_bring_declarations_user_id', 'bring_declarations', 'users', ['user_id'], ['id'], ondelete='SET NULL')
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_constraint('fk_reservations_user_id', 'reservations', type_='foreignkey')
|
||||
op.drop_column('reservations', 'user_id')
|
||||
|
||||
op.drop_constraint('fk_contributions_user_id', 'contributions', type_='foreignkey')
|
||||
op.drop_column('contributions', 'user_id')
|
||||
|
||||
op.drop_constraint('fk_bring_declarations_user_id', 'bring_declarations', type_='foreignkey')
|
||||
op.drop_column('bring_declarations', 'user_id')
|
||||
21
alembic/versions/0014_bring_item_url.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""add url to bring_items
|
||||
|
||||
Revision ID: 0014
|
||||
Revises: 0013
|
||||
Create Date: 2026-05-30
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0014'
|
||||
down_revision = '0013'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('bring_items', sa.Column('url', sa.String(2048), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('bring_items', 'url')
|
||||
22
alembic/versions/0015_rename_service_types.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""rename service_types gift→kdo bring→kontrib
|
||||
|
||||
Revision ID: 0015
|
||||
Revises: 0014
|
||||
Create Date: 2026-05-30
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = '0015'
|
||||
down_revision = '0014'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("UPDATE event_services SET service_type = 'kdo' WHERE service_type = 'gift'")
|
||||
op.execute("UPDATE event_services SET service_type = 'kontrib' WHERE service_type = 'bring'")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("UPDATE event_services SET service_type = 'gift' WHERE service_type = 'kdo'")
|
||||
op.execute("UPDATE event_services SET service_type = 'bring' WHERE service_type = 'kontrib'")
|
||||
43
alembic/versions/0016_rename_tables_columns.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""rename gift/bring tables and columns to kdo/kontrib
|
||||
|
||||
Revision ID: 0016
|
||||
Revises: 0015
|
||||
Create Date: 2026-05-30
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = '0016'
|
||||
down_revision = '0015'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Rename columns before tables (FK constraints stay valid)
|
||||
op.alter_column('reservations', 'gift_id', new_column_name='kdo_item_id')
|
||||
op.alter_column('contributions', 'gift_id', new_column_name='kdo_item_id')
|
||||
|
||||
# bring_list_id columns (must rename before renaming the tables they reference)
|
||||
op.execute('ALTER TABLE bring_items RENAME COLUMN bring_list_id TO kontrib_list_id')
|
||||
op.execute('ALTER TABLE bring_suggestions RENAME COLUMN bring_list_id TO kontrib_list_id')
|
||||
|
||||
# Rename tables
|
||||
op.rename_table('gifts', 'kdo_items')
|
||||
op.rename_table('bring_lists', 'kontrib_lists')
|
||||
op.rename_table('bring_items', 'kontrib_items')
|
||||
op.rename_table('bring_declarations', 'kontrib_declarations')
|
||||
op.rename_table('bring_suggestions', 'kontrib_suggestions')
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.rename_table('kdo_items', 'gifts')
|
||||
op.rename_table('kontrib_lists', 'bring_lists')
|
||||
op.rename_table('kontrib_items', 'bring_items')
|
||||
op.rename_table('kontrib_declarations', 'bring_declarations')
|
||||
op.rename_table('kontrib_suggestions', 'bring_suggestions')
|
||||
|
||||
op.execute('ALTER TABLE kontrib_items RENAME COLUMN kontrib_list_id TO bring_list_id')
|
||||
op.execute('ALTER TABLE kontrib_suggestions RENAME COLUMN kontrib_list_id TO bring_list_id')
|
||||
|
||||
op.alter_column('reservations', 'kdo_item_id', new_column_name='gift_id')
|
||||
op.alter_column('contributions', 'kdo_item_id', new_column_name='gift_id')
|
||||
60
alembic/versions/0017_kount.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""kount — groupes de frais partagés
|
||||
|
||||
Revision ID: 0017
|
||||
Revises: 0016
|
||||
Create Date: 2026-05-30
|
||||
"""
|
||||
import uuid
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = '0017'
|
||||
down_revision = '0016'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'kount_groups',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True, default=uuid.uuid4),
|
||||
sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('event_id', UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text, nullable=True),
|
||||
sa.Column('currency', sa.String(10), server_default='EUR', nullable=False),
|
||||
sa.Column('share_token', UUID(as_uuid=True), unique=True, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_kount_groups_user_id', 'kount_groups', ['user_id'])
|
||||
|
||||
op.create_table(
|
||||
'kount_members',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True, default=uuid.uuid4),
|
||||
sa.Column('group_id', UUID(as_uuid=True), sa.ForeignKey('kount_groups.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('email', sa.String(255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_kount_members_group_id', 'kount_members', ['group_id'])
|
||||
|
||||
op.create_table(
|
||||
'kount_expenses',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True, default=uuid.uuid4),
|
||||
sa.Column('group_id', UUID(as_uuid=True), sa.ForeignKey('kount_groups.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('paid_by_member_id', UUID(as_uuid=True), sa.ForeignKey('kount_members.id', ondelete='SET NULL'), nullable=True),
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('amount', sa.Numeric(10, 2), nullable=False),
|
||||
sa.Column('expense_date', sa.Date, nullable=False),
|
||||
sa.Column('participant_name', sa.String(100), nullable=True),
|
||||
sa.Column('cancel_token', UUID(as_uuid=True), unique=True, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_kount_expenses_group_id', 'kount_expenses', ['group_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('kount_expenses')
|
||||
op.drop_table('kount_members')
|
||||
op.drop_table('kount_groups')
|
||||
31
alembic/versions/0018_kount_expense_splits.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""kount expense splits
|
||||
|
||||
Revision ID: 0018
|
||||
Revises: 0017
|
||||
Create Date: 2026-05-31
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = '0018'
|
||||
down_revision = '0017'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'kount_expense_splits',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('expense_id', postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey('kount_expenses.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('member_id', postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey('kount_members.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('share_pct', sa.Numeric(5, 2), nullable=False),
|
||||
)
|
||||
op.create_index('ix_expense_splits_expense_id', 'kount_expense_splits', ['expense_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('kount_expense_splits')
|
||||
31
alembic/versions/0019_kount_group_default_splits.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""kount group default splits
|
||||
|
||||
Revision ID: 0019
|
||||
Revises: 0018
|
||||
Create Date: 2026-05-31
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = '0019'
|
||||
down_revision = '0018'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'kount_group_default_splits',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('group_id', postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey('kount_groups.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('member_id', postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey('kount_members.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('share_pct', sa.Numeric(5, 2), nullable=False),
|
||||
)
|
||||
op.create_index('ix_group_default_splits_group_id', 'kount_group_default_splits', ['group_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('kount_group_default_splits')
|
||||
21
alembic/versions/0020_kdo_item_image_url.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""kdo_item image_url
|
||||
|
||||
Revision ID: 0020
|
||||
Revises: 0019
|
||||
Create Date: 2026-06-01
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0020"
|
||||
down_revision = "0019"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("kdo_items", sa.Column("image_url", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("kdo_items", "image_url")
|
||||
39
alembic/versions/0021_rename_legacy_tables.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""rename legacy tables to canonical names
|
||||
|
||||
Revision ID: 0021
|
||||
Revises: 0020
|
||||
Create Date: 2026-06-02
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0021"
|
||||
down_revision = "0020"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Drop dead RSVP table (feature removed)
|
||||
op.drop_table("event_rsvps")
|
||||
|
||||
# Hub tables
|
||||
op.rename_table("events", "hub_events")
|
||||
op.rename_table("event_services", "hub_services")
|
||||
op.rename_table("event_notifications", "hub_notifications")
|
||||
|
||||
# Kdo tables
|
||||
op.rename_table("lists", "kdo_lists")
|
||||
op.rename_table("reservations", "kdo_reservations")
|
||||
op.rename_table("contributions", "kdo_contributions")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.rename_table("kdo_contributions", "contributions")
|
||||
op.rename_table("kdo_reservations", "reservations")
|
||||
op.rename_table("kdo_lists", "lists")
|
||||
|
||||
op.rename_table("hub_notifications", "event_notifications")
|
||||
op.rename_table("hub_services", "event_services")
|
||||
op.rename_table("hub_events", "events")
|
||||
|
||||
# Recreating event_rsvps on downgrade is not supported (data is gone)
|
||||
94
alembic/versions/0022_kal.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""kal service tables
|
||||
|
||||
Revision ID: 0022
|
||||
Revises: 0021
|
||||
Create Date: 2026-06-06
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0022"
|
||||
down_revision = "0021"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"kal_polls",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("event_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("share_token", postgresql.UUID(as_uuid=True), unique=True, nullable=False),
|
||||
sa.Column("votes_nominatif_public", sa.Boolean, nullable=False, server_default="false"),
|
||||
sa.Column("is_closed", sa.Boolean, nullable=False, server_default="false"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"kal_time_ranges",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("poll_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kal_polls.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("start_time", sa.Time, nullable=False),
|
||||
sa.Column("end_time", sa.Time, nullable=False),
|
||||
sa.Column("position", sa.Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"kal_locations",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("poll_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kal_polls.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("label", sa.String(100), nullable=False),
|
||||
sa.Column("position", sa.Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"kal_dates",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("poll_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kal_polls.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("date", sa.Date, nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"kal_slots",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("poll_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kal_polls.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("date_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kal_dates.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("time_range_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kal_time_ranges.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("location_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kal_locations.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("position", sa.Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"kal_participants",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("poll_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kal_polls.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=True),
|
||||
sa.Column("cancel_token", postgresql.UUID(as_uuid=True), unique=True, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"kal_responses",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("participant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kal_participants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("slot_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kal_slots.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("answer", sa.Enum("yes", "maybe", "no", name="kal_answer"), nullable=False),
|
||||
sa.UniqueConstraint("participant_id", "slot_id", name="uq_kal_response_participant_slot"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("kal_responses")
|
||||
op.drop_constraint("uq_kal_response_participant_slot", "kal_responses", type_="unique")
|
||||
op.drop_table("kal_participants")
|
||||
op.drop_table("kal_slots")
|
||||
op.drop_table("kal_dates")
|
||||
op.drop_table("kal_locations")
|
||||
op.drop_table("kal_time_ranges")
|
||||
op.drop_table("kal_polls")
|
||||
op.execute("DROP TYPE IF EXISTS kal_answer")
|
||||
63
alembic/versions/0023_kwiz.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""kwiz service tables
|
||||
|
||||
Revision ID: 0023
|
||||
Revises: 0022
|
||||
Create Date: 2026-06-14
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0023"
|
||||
down_revision = "0022"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"kwiz_polls",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("event_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("title", sa.String(500), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("share_token", postgresql.UUID(as_uuid=True), unique=True, nullable=False),
|
||||
sa.Column("allow_multiple", sa.Boolean, nullable=False, server_default="false"),
|
||||
sa.Column("results_public", sa.Boolean, nullable=False, server_default="false"),
|
||||
sa.Column("is_closed", sa.Boolean, nullable=False, server_default="false"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"kwiz_options",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("poll_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kwiz_polls.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("label", sa.String(255), nullable=False),
|
||||
sa.Column("position", sa.Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"kwiz_participants",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("poll_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kwiz_polls.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=True),
|
||||
sa.Column("cancel_token", postgresql.UUID(as_uuid=True), unique=True, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"kwiz_votes",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("participant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kwiz_participants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("option_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("kwiz_options.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.UniqueConstraint("participant_id", "option_id", name="uq_kwiz_vote_participant_option"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("kwiz_votes")
|
||||
op.drop_table("kwiz_participants")
|
||||
op.drop_table("kwiz_options")
|
||||
op.drop_table("kwiz_polls")
|
||||
25
alembic/versions/0024_premium_subscription.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""premium subscription fields on users
|
||||
|
||||
Revision ID: 0024
|
||||
Revises: 0023
|
||||
Create Date: 2026-06-14
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0024"
|
||||
down_revision = "0023"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("users", sa.Column("stripe_subscription_id", sa.String(255), nullable=True))
|
||||
op.add_column("users", sa.Column("subscription_status", sa.String(50), nullable=True))
|
||||
op.add_column("users", sa.Column("subscription_plan", sa.String(20), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("users", "subscription_plan")
|
||||
op.drop_column("users", "subscription_status")
|
||||
op.drop_column("users", "stripe_subscription_id")
|
||||
24
alembic/versions/0025_cancel_at_period_end.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""cancel_at_period_end flag on users
|
||||
|
||||
Revision ID: 0025
|
||||
Revises: 0024
|
||||
Create Date: 2026-06-14
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0025"
|
||||
down_revision = "0024"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("cancel_at_period_end", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("users", "cancel_at_period_end")
|
||||
0
api/__init__.py
Normal file
50
api/config.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
database_url: str
|
||||
secret_key: str
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 10080
|
||||
magic_link_expire_minutes: int = 15
|
||||
co_owner_invite_expire_minutes: int = 10080 # 7 jours
|
||||
|
||||
smtp_host: str = "mail.infomaniak.com"
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
from_email: str = "noreply@kankwa.fr"
|
||||
from_name: str = "Kankwa"
|
||||
|
||||
stripe_secret_key: str = ""
|
||||
stripe_publishable_key: str = ""
|
||||
stripe_webhook_secret: str = ""
|
||||
stripe_price_monthly: str = ""
|
||||
stripe_price_annual: str = ""
|
||||
|
||||
frontend_url: str = "http://localhost:3000"
|
||||
environment: str = "development"
|
||||
|
||||
max_free_active_services: int = 3 # toutes catégories confondues
|
||||
|
||||
# Amazon Product Advertising API
|
||||
amazon_access_key: str = ""
|
||||
amazon_secret_key: str = ""
|
||||
amazon_partner_tag: str = "" # ex: kankwa-21
|
||||
|
||||
# Awin
|
||||
awin_publisher_id: str = ""
|
||||
awin_api_token: str = ""
|
||||
|
||||
# TradeDoubler
|
||||
tradedoubler_site_id: str = ""
|
||||
tradedoubler_client_id: str = ""
|
||||
tradedoubler_client_secret: str = ""
|
||||
|
||||
# Admin
|
||||
admin_token: str = ""
|
||||
|
||||
|
||||
settings = Settings()
|
||||
93
api/main.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api.config import settings
|
||||
from api.routers import auth, billing, scrape, sitemap
|
||||
from services.admin.router import router as admin_router
|
||||
from services.comments.router import router as comments_router
|
||||
from services.ownership.router import router as ownership_router
|
||||
from services.kdo.router import router as kdo_router
|
||||
from services.hub.router import router as hub_router
|
||||
from services.kontrib.router import router as kontrib_router
|
||||
from services.kount.router import router as kount_router
|
||||
from services.kal.router import router as kal_router
|
||||
from services.kwiz.router import router as kwiz_router
|
||||
from shared.rate_limit.middleware import RateLimitMiddleware
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if settings.environment == "development" else logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _purge_loop() -> None:
|
||||
from shared.auth.purge import purge_expired_tokens
|
||||
from shared.database.connection import AsyncSessionLocal
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await purge_expired_tokens(db)
|
||||
except Exception:
|
||||
logger.exception("Erreur lors de la purge des tokens expirés")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
task = asyncio.create_task(_purge_loop())
|
||||
yield
|
||||
task.cancel()
|
||||
|
||||
|
||||
_docs = None if settings.environment == "production" else "/docs"
|
||||
_redoc = None if settings.environment == "production" else "/redoc"
|
||||
app = FastAPI(title="Kankwa API", version="1.0.0", docs_url=_docs, redoc_url=_redoc, lifespan=lifespan)
|
||||
|
||||
_origins = [settings.frontend_url]
|
||||
if settings.environment != "production":
|
||||
_origins += ["http://localhost:3000", "http://localhost:5173"]
|
||||
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
start = time.time()
|
||||
response = await call_next(request)
|
||||
duration = (time.time() - start) * 1000
|
||||
logger.info("%s %s → %s (%.0fms)", request.method, request.url.path, response.status_code, duration)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(sitemap.router, tags=["sitemap"])
|
||||
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(billing.router, prefix="/api/billing", tags=["billing"])
|
||||
app.include_router(scrape.router, prefix="/api/shared", tags=["shared"])
|
||||
app.include_router(comments_router, prefix="/api/shared/comments", tags=["comments"])
|
||||
app.include_router(ownership_router, prefix="/api/shared/ownership", tags=["ownership"])
|
||||
app.include_router(kdo_router, prefix="/api/kdo", tags=["kdo"])
|
||||
app.include_router(hub_router, prefix="/api/hub", tags=["hub"])
|
||||
app.include_router(kontrib_router, prefix="/api/kontrib", tags=["kontrib"])
|
||||
app.include_router(kount_router, prefix="/api/kount", tags=["kount"])
|
||||
app.include_router(kal_router, prefix="/api/kal", tags=["kal"])
|
||||
app.include_router(kwiz_router, prefix="/api/kwiz", tags=["kwiz"])
|
||||
0
api/routers/__init__.py
Normal file
359
api/routers/auth.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr, Field, computed_field, field_validator
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.config import settings
|
||||
from models.user import User
|
||||
from shared.auth.dependencies import get_current_user
|
||||
from shared.auth.jwt import create_access_token, create_email_change_token, decode_email_change_token
|
||||
from shared.auth.magic_link import create_magic_token, verify_magic_token
|
||||
from shared.auth.verification_code import create_verification_code, verify_code
|
||||
from shared.auth.password import hash_password, verify_password
|
||||
from shared.database.connection import get_db
|
||||
from shared.email import sender as email_sender
|
||||
from shared.email.sender import safe_send
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_strength(cls, v: str) -> str:
|
||||
errors = []
|
||||
if len(v) < 8:
|
||||
errors.append("8 caractères minimum")
|
||||
if not re.search(r'[A-Z]', v):
|
||||
errors.append("une majuscule")
|
||||
if not re.search(r'[a-z]', v):
|
||||
errors.append("une minuscule")
|
||||
if not re.search(r'\d', v):
|
||||
errors.append("un chiffre")
|
||||
if not re.search(r'[^A-Za-z0-9]', v):
|
||||
errors.append("un caractère spécial")
|
||||
if errors:
|
||||
raise ValueError("Le mot de passe doit contenir : " + ", ".join(errors))
|
||||
return v
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class MagicLinkRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
email: EmailStr
|
||||
code: str
|
||||
|
||||
|
||||
class ResendVerificationRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
id: UUID
|
||||
email: str
|
||||
email_verified: bool
|
||||
is_premium: bool
|
||||
premium_until: datetime | None = None
|
||||
subscription_plan: str | None = None
|
||||
subscription_status: str | None = None
|
||||
cancel_at_period_end: bool = False
|
||||
created_at: datetime
|
||||
password_hash: str | None = Field(exclude=True, default=None)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def has_password(self) -> bool:
|
||||
return self.password_hash is not None
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class SetPasswordRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_min_length(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("Le mot de passe doit faire au moins 8 caractères")
|
||||
return v
|
||||
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/register", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
if user.password_hash and user.email_verified:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email déjà utilisé")
|
||||
user.password_hash = hash_password(body.password)
|
||||
await db.commit()
|
||||
else:
|
||||
user = User(email=body.email, password_hash=hash_password(body.password))
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
|
||||
code = await create_verification_code(user.email, db)
|
||||
await safe_send(email_sender.send_email_verification(user.email, code), logger)
|
||||
|
||||
return MessageResponse(message="Code de vérification envoyé")
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.password_hash or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Email ou mot de passe incorrect")
|
||||
if not user.email_verified:
|
||||
code = await create_verification_code(user.email, db)
|
||||
await safe_send(email_sender.send_email_verification(user.email, code), logger)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="email_not_verified")
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/magic-link", response_model=MessageResponse)
|
||||
async def request_magic_link(body: MagicLinkRequest, db: AsyncSession = Depends(get_db)):
|
||||
token = await create_magic_token(body.email, db)
|
||||
magic_url = f"{settings.frontend_url}/magic?token={token}"
|
||||
if settings.environment != "production":
|
||||
logger.info("DEV — magic link pour %s : %s", body.email, magic_url)
|
||||
await safe_send(email_sender.send_magic_link(body.email, magic_url), logger)
|
||||
return MessageResponse(message="Si un compte existe, un email de connexion a été envoyé")
|
||||
|
||||
|
||||
@router.get("/magic-link/verify", response_model=TokenResponse)
|
||||
async def verify_magic_link(token: str, db: AsyncSession = Depends(get_db)):
|
||||
email = await verify_magic_token(token, db)
|
||||
if email is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Lien invalide ou expiré")
|
||||
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
is_new = user is None
|
||||
if is_new:
|
||||
user = User(email=email, email_verified=True)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
elif not user.email_verified:
|
||||
user.email_verified = True
|
||||
await db.commit()
|
||||
|
||||
if is_new:
|
||||
set_password_token = await create_magic_token(email, db)
|
||||
set_password_url = f"{settings.frontend_url}/magic?token={set_password_token}&next=/set-password"
|
||||
await safe_send(email_sender.send_welcome_email(email, set_password_url), logger)
|
||||
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/verify-email", response_model=TokenResponse)
|
||||
async def verify_email(body: VerifyEmailRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Compte introuvable")
|
||||
if user.email_verified:
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
|
||||
ok = await verify_code(user.email, body.code, db)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Code invalide ou expiré")
|
||||
user.email_verified = True
|
||||
await db.commit()
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/resend-verification", response_model=MessageResponse)
|
||||
async def resend_verification(body: ResendVerificationRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
user = result.scalar_one_or_none()
|
||||
if user and not user.email_verified:
|
||||
code = await create_verification_code(user.email, db)
|
||||
try:
|
||||
await email_sender.send_email_verification(user.email, code)
|
||||
except Exception:
|
||||
logger.exception("Échec renvoi code de vérification à %s", user.email)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Erreur d'envoi email")
|
||||
# Même réponse qu'il existe ou non (sécurité)
|
||||
return MessageResponse(message="Code renvoyé si le compte existe")
|
||||
|
||||
|
||||
@router.post("/set-password", response_model=MessageResponse)
|
||||
async def set_password(
|
||||
body: SetPasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if current_user.password_hash:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Un mot de passe est déjà défini")
|
||||
current_user.password_hash = hash_password(body.password)
|
||||
await db.commit()
|
||||
logger.info("Mot de passe défini pour %s", current_user.email)
|
||||
return MessageResponse(message="Mot de passe défini avec succès")
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def me(current_user: User = Depends(get_current_user)):
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@router.get("/me/usage")
|
||||
async def me_usage(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
from models.kdo import KdoList
|
||||
from models.kontrib import KontribList
|
||||
from models.kount import KountGroup
|
||||
from models.kal import KalPoll
|
||||
from models.kwiz import KwizPoll
|
||||
from models.event import Event
|
||||
from shared.service_registry import SERVICES, HUB
|
||||
|
||||
_MODEL_MAP = {"hub": Event, "kdo": KdoList, "kontrib": KontribList, "kount": KountGroup, "kal": KalPoll, "kwiz": KwizPoll}
|
||||
_REGISTRY = [
|
||||
{"key": svc["table"], "label": svc["label"], "model": _MODEL_MAP[svc["service_type"]], "counted": svc["counted"], "icon_id": svc["icon_id"]}
|
||||
for svc in [HUB, *SERVICES]
|
||||
]
|
||||
|
||||
service_data = []
|
||||
total = 0
|
||||
for svc in _REGISTRY:
|
||||
count = (await db.execute(select(func.count()).where(svc["model"].user_id == current_user.id))).scalar() or 0
|
||||
if svc["counted"]:
|
||||
total += count
|
||||
service_data.append({
|
||||
"key": svc["key"],
|
||||
"label": svc["label"],
|
||||
"count": count,
|
||||
"counted": svc["counted"],
|
||||
"icon_id": svc["icon_id"],
|
||||
})
|
||||
|
||||
return {
|
||||
"services": service_data,
|
||||
"total": total,
|
||||
"max": settings.max_free_active_services,
|
||||
"is_premium": current_user.is_premium,
|
||||
}
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str | None = None
|
||||
new_password: str
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def password_strength(cls, v: str) -> str:
|
||||
errors = []
|
||||
if len(v) < 8: errors.append("8 caractères minimum")
|
||||
if not re.search(r'[A-Z]', v): errors.append("une majuscule")
|
||||
if not re.search(r'[a-z]', v): errors.append("une minuscule")
|
||||
if not re.search(r'\d', v): errors.append("un chiffre")
|
||||
if not re.search(r'[^A-Za-z0-9]', v): errors.append("un caractère spécial")
|
||||
if errors:
|
||||
raise ValueError("Le mot de passe doit contenir : " + ", ".join(errors))
|
||||
return v
|
||||
|
||||
|
||||
@router.patch("/change-password", response_model=MessageResponse)
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if current_user.password_hash:
|
||||
if not body.current_password or not verify_password(body.current_password, current_user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Mot de passe actuel incorrect")
|
||||
current_user.password_hash = hash_password(body.new_password)
|
||||
await db.commit()
|
||||
return MessageResponse(message="Mot de passe mis à jour")
|
||||
|
||||
|
||||
class RequestEmailChangeRequest(BaseModel):
|
||||
new_email: EmailStr
|
||||
|
||||
|
||||
@router.post("/request-email-change", response_model=MessageResponse)
|
||||
async def request_email_change(
|
||||
body: RequestEmailChangeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if body.new_email == current_user.email:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="C'est déjà ton email actuel")
|
||||
existing = (await db.execute(select(User).where(User.email == body.new_email))).scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Cet email est déjà utilisé")
|
||||
token = create_email_change_token(current_user.id, body.new_email)
|
||||
confirm_url = f"{settings.frontend_url}/compte?confirm-email={token}"
|
||||
try:
|
||||
await email_sender.send_email_change_verification(body.new_email, confirm_url)
|
||||
except Exception:
|
||||
logger.exception("Échec envoi email de changement d'email")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Erreur d'envoi email")
|
||||
return MessageResponse(message=f"Email de confirmation envoyé à {body.new_email}")
|
||||
|
||||
|
||||
@router.get("/confirm-email-change", response_model=TokenResponse)
|
||||
async def confirm_email_change(token: str, db: AsyncSession = Depends(get_db)):
|
||||
result = decode_email_change_token(token)
|
||||
if not result:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Lien invalide ou expiré")
|
||||
user_id_str, new_email = result
|
||||
from uuid import UUID as _UUID
|
||||
user = await db.get(User, _UUID(user_id_str))
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Compte introuvable")
|
||||
existing = (await db.execute(select(User).where(User.email == new_email))).scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Cet email est déjà utilisé")
|
||||
user.email = new_email
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
|
||||
|
||||
|
||||
class DeleteAccountRequest(BaseModel):
|
||||
confirmation: str
|
||||
|
||||
|
||||
@router.delete("/account", response_model=MessageResponse)
|
||||
async def delete_account(
|
||||
body: DeleteAccountRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if body.confirmation != "SUPPRIMER":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Confirmation incorrecte")
|
||||
await db.delete(current_user)
|
||||
await db.commit()
|
||||
return MessageResponse(message="Compte supprimé")
|
||||
180
api/routers/billing.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""Abonnement Premium — Stripe Checkout + Customer Portal (Phase 9).
|
||||
|
||||
`is_premium` est l'autorité unique côté app et n'est modifié QUE par le webhook
|
||||
signé ci-dessous — jamais par une route appelée par le client.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.user import User
|
||||
from shared.auth.dependencies import get_current_user
|
||||
from shared.database.connection import get_db
|
||||
from shared.email import sender as email_sender
|
||||
from shared.email.sender import safe_send
|
||||
from shared.payments import stripe_client
|
||||
from api.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class CheckoutRequest(BaseModel):
|
||||
plan: str # "monthly" | "annual"
|
||||
|
||||
|
||||
class UrlResponse(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class CheckoutResponse(BaseModel):
|
||||
client_secret: str
|
||||
|
||||
|
||||
class ConfigResponse(BaseModel):
|
||||
publishable_key: str
|
||||
|
||||
|
||||
# ── Routes client (authentifiées) ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/config", response_model=ConfigResponse)
|
||||
async def get_config():
|
||||
# Clé publique Stripe (non secrète) — utilisée par Stripe.js côté front.
|
||||
return ConfigResponse(publishable_key=settings.stripe_publishable_key)
|
||||
|
||||
|
||||
@router.post("/checkout", response_model=CheckoutResponse)
|
||||
async def create_checkout(
|
||||
body: CheckoutRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if current_user.is_premium:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Abonnement déjà actif")
|
||||
price_id = stripe_client.PRICE_BY_PLAN.get(body.plan)
|
||||
if not price_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Plan inconnu")
|
||||
try:
|
||||
client_secret = await stripe_client.create_checkout_session(current_user, price_id, db)
|
||||
except Exception:
|
||||
logger.exception("Création session Checkout échouée pour user=%s", current_user.id)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Erreur de paiement, réessayez")
|
||||
return CheckoutResponse(client_secret=client_secret)
|
||||
|
||||
|
||||
@router.post("/portal", response_model=UrlResponse)
|
||||
async def create_portal(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not current_user.stripe_customer_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Aucun abonnement à gérer")
|
||||
try:
|
||||
url = await stripe_client.create_portal_session(current_user)
|
||||
except Exception:
|
||||
logger.exception("Création session Portal échouée pour user=%s", current_user.id)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Erreur, réessayez")
|
||||
return UrlResponse(url=url)
|
||||
|
||||
|
||||
# ── Webhook Stripe (non authentifié, signature vérifiée) ────────────────────────
|
||||
|
||||
def _period_end(subscription) -> datetime | None:
|
||||
"""current_period_end peut être au niveau subscription ou item selon la version d'API."""
|
||||
ts = subscription.get("current_period_end")
|
||||
if ts is None:
|
||||
items = (subscription.get("items") or {}).get("data") or []
|
||||
if items:
|
||||
ts = items[0].get("current_period_end")
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc) if ts else None
|
||||
|
||||
|
||||
def _plan_from_subscription(subscription) -> str | None:
|
||||
items = (subscription.get("items") or {}).get("data") or []
|
||||
if items:
|
||||
price_id = (items[0].get("price") or {}).get("id")
|
||||
return stripe_client.PLAN_BY_PRICE.get(price_id)
|
||||
return None
|
||||
|
||||
|
||||
async def _user_by_customer(db: AsyncSession, customer_id: str) -> User | None:
|
||||
if not customer_id:
|
||||
return None
|
||||
return (await db.execute(select(User).where(User.stripe_customer_id == customer_id))).scalar_one_or_none()
|
||||
|
||||
|
||||
def _apply_subscription(user: User, subscription) -> None:
|
||||
"""Reflète l'état d'une Subscription Stripe sur l'utilisateur."""
|
||||
sub_status = subscription.get("status")
|
||||
user.stripe_subscription_id = subscription.get("id")
|
||||
user.subscription_status = sub_status
|
||||
user.subscription_plan = _plan_from_subscription(subscription) or user.subscription_plan
|
||||
user.premium_until = _period_end(subscription) or user.premium_until
|
||||
user.cancel_at_period_end = bool(subscription.get("cancel_at_period_end"))
|
||||
user.is_premium = sub_status in {"active", "trialing"}
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def webhook(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
payload = await request.body()
|
||||
sig = request.headers.get("stripe-signature", "")
|
||||
try:
|
||||
event = stripe_client.construct_event(payload, sig)
|
||||
except Exception:
|
||||
logger.warning("Webhook Stripe : signature invalide")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Signature invalide")
|
||||
|
||||
etype = event["type"]
|
||||
obj = event["data"]["object"]
|
||||
|
||||
if etype == "checkout.session.completed":
|
||||
user = await _user_by_customer(db, obj.get("customer"))
|
||||
if not user and obj.get("client_reference_id"):
|
||||
try:
|
||||
user = await db.get(User, UUID(obj["client_reference_id"]))
|
||||
except ValueError:
|
||||
user = None
|
||||
if user:
|
||||
if not user.stripe_customer_id:
|
||||
user.stripe_customer_id = obj.get("customer")
|
||||
sub_id = obj.get("subscription")
|
||||
if sub_id:
|
||||
subscription = await stripe_client.retrieve_subscription(sub_id)
|
||||
_apply_subscription(user, subscription)
|
||||
else:
|
||||
user.is_premium = True
|
||||
user.subscription_status = "active"
|
||||
await db.commit()
|
||||
await safe_send(
|
||||
email_sender.send_premium_welcome(user.email, f"{settings.frontend_url}/compte"),
|
||||
logger,
|
||||
)
|
||||
|
||||
elif etype in ("customer.subscription.updated", "customer.subscription.created"):
|
||||
user = await _user_by_customer(db, obj.get("customer"))
|
||||
if user:
|
||||
_apply_subscription(user, obj)
|
||||
await db.commit()
|
||||
|
||||
elif etype == "customer.subscription.deleted":
|
||||
user = await _user_by_customer(db, obj.get("customer"))
|
||||
if user:
|
||||
user.is_premium = False
|
||||
user.subscription_status = "canceled"
|
||||
user.cancel_at_period_end = False
|
||||
# premium_until conservé : fin de la période déjà payée
|
||||
await db.commit()
|
||||
|
||||
elif etype == "invoice.payment_failed":
|
||||
user = await _user_by_customer(db, obj.get("customer"))
|
||||
if user:
|
||||
user.subscription_status = "past_due"
|
||||
await db.commit()
|
||||
|
||||
return {"received": True}
|
||||
704
api/routers/scrape.py
Normal file
|
|
@ -0,0 +1,704 @@
|
|||
import html as html_lib
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from collections import Counter
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import extruct
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from price_parser import Price
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.event import Event
|
||||
from models.kal import KalPoll
|
||||
from models.kdo import KdoList
|
||||
from models.kontrib import KontribList
|
||||
from models.kount import KountGroup
|
||||
from models.kwiz import KwizPoll
|
||||
from shared.auth.dependencies import get_current_user
|
||||
from shared.database.connection import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_PRIVATE_NETS = [
|
||||
ipaddress.ip_network("0.0.0.0/8"), # "this network"
|
||||
ipaddress.ip_network("127.0.0.0/8"), # loopback
|
||||
ipaddress.ip_network("10.0.0.0/8"), # privé
|
||||
ipaddress.ip_network("172.16.0.0/12"), # privé
|
||||
ipaddress.ip_network("192.168.0.0/16"), # privé
|
||||
ipaddress.ip_network("169.254.0.0/16"), # link-local (cloud metadata 169.254.169.254)
|
||||
ipaddress.ip_network("100.64.0.0/10"), # CGNAT
|
||||
ipaddress.ip_network("::1/128"), # loopback v6
|
||||
ipaddress.ip_network("fc00::/7"), # ULA v6
|
||||
ipaddress.ip_network("fe80::/10"), # link-local v6
|
||||
]
|
||||
|
||||
|
||||
def _is_blocked_ip(addr) -> bool:
|
||||
return (
|
||||
addr.is_private or addr.is_loopback or addr.is_link_local
|
||||
or addr.is_reserved or addr.is_multicast or addr.is_unspecified
|
||||
or any(addr in net for net in _PRIVATE_NETS)
|
||||
)
|
||||
|
||||
|
||||
def _validate_url(url: str) -> None:
|
||||
"""Refuse les URLs pointant vers le réseau interne (anti-SSRF).
|
||||
|
||||
En cas d'échec de résolution DNS, on refuse (fail-closed) plutôt que d'autoriser.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Schéma URL non autorisé")
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="URL invalide")
|
||||
|
||||
# IP littérale dans l'URL
|
||||
try:
|
||||
if _is_blocked_ip(ipaddress.ip_address(hostname)):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="URL non autorisée")
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Nom d'hôte → résoudre TOUTES les adresses (v4 + v6) et toutes les bloquer si l'une est interne
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Hôte introuvable")
|
||||
for info in infos:
|
||||
ip_str = info[4][0]
|
||||
try:
|
||||
if _is_blocked_ip(ipaddress.ip_address(ip_str)):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="URL non autorisée")
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Referer": "https://www.google.com/",
|
||||
"DNT": "1",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
}
|
||||
|
||||
|
||||
class ScrapeResult(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
price: float | None = None
|
||||
image: str | None = None
|
||||
affiliate_url: str | None = None
|
||||
|
||||
|
||||
class ParseRequest(BaseModel):
|
||||
url: str
|
||||
html: str
|
||||
|
||||
|
||||
# ── Price parsing (price-parser) ───────────────────────────────────────────────
|
||||
|
||||
def _extract_price(raw) -> float | None:
|
||||
"""Parse any price representation via price-parser (900+ real-world test cases)."""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, (int, float)):
|
||||
p = float(raw)
|
||||
return p if _is_valid_price(p) else None
|
||||
parsed = Price.fromstring(str(raw))
|
||||
if parsed.amount is None:
|
||||
return None
|
||||
return float(parsed.amount)
|
||||
|
||||
|
||||
def _is_valid_price(p: float | None) -> bool:
|
||||
return p is not None and 0.01 <= p <= 100_000
|
||||
|
||||
|
||||
# ── Structured data (extruct: JSON-LD + microdata uniform) ────────────────────
|
||||
|
||||
def _get_structured(html: str, url: str) -> dict:
|
||||
try:
|
||||
return extruct.extract(
|
||||
html,
|
||||
base_url=url,
|
||||
syntaxes=["json-ld", "microdata", "opengraph"],
|
||||
uniform=True,
|
||||
errors="ignore",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("extruct error: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def _find_product_node(data) -> dict | None:
|
||||
"""Recursively find first schema.org/Product node."""
|
||||
if isinstance(data, dict):
|
||||
t = data.get("@type", "")
|
||||
if t == "Product" or (isinstance(t, list) and "Product" in t):
|
||||
return data
|
||||
for v in data.values():
|
||||
if isinstance(v, (dict, list)):
|
||||
found = _find_product_node(v)
|
||||
if found:
|
||||
return found
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
found = _find_product_node(item)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _get_product_node(structured: dict) -> dict:
|
||||
"""Find Product node from JSON-LD or microdata (both in uniform format)."""
|
||||
for syntax in ("json-ld", "microdata"):
|
||||
for item in structured.get(syntax, []):
|
||||
found = _find_product_node(item)
|
||||
if found:
|
||||
return found
|
||||
return {}
|
||||
|
||||
|
||||
def _og(structured: dict, prop: str) -> str | None:
|
||||
"""Extract OpenGraph property from extruct output."""
|
||||
for item in structured.get("opengraph", []):
|
||||
if isinstance(item, dict) and item.get("@type") == prop:
|
||||
return item.get("value") or item.get("content")
|
||||
# flat dict fallback
|
||||
og = structured.get("opengraph", {})
|
||||
if isinstance(og, dict):
|
||||
return og.get(prop)
|
||||
return None
|
||||
|
||||
|
||||
# ── Price from structured data ────────────────────────────────────────────────
|
||||
|
||||
def _price_from_product_node(jld: dict) -> float | None:
|
||||
if not jld:
|
||||
return None
|
||||
offers = jld.get("offers")
|
||||
if not offers:
|
||||
return None
|
||||
if isinstance(offers, dict):
|
||||
offers = [offers]
|
||||
|
||||
regular = [o for o in offers if isinstance(o, dict) and o.get("@type") != "AggregateOffer"]
|
||||
aggregate = [o for o in offers if isinstance(o, dict) and o.get("@type") == "AggregateOffer"]
|
||||
|
||||
# Regular Offer: prefer InStock, take first valid price (NOT min — avoid cheapest variant trap)
|
||||
in_stock = [o for o in regular if "InStock" in str(o.get("availability", ""))]
|
||||
for o in (in_stock or regular):
|
||||
raw = o.get("price")
|
||||
if raw is not None:
|
||||
p = _extract_price(raw)
|
||||
if _is_valid_price(p):
|
||||
return p
|
||||
spec = o.get("priceSpecification")
|
||||
if isinstance(spec, list):
|
||||
spec = spec[0] if spec else None
|
||||
if isinstance(spec, dict):
|
||||
p = _extract_price(spec.get("price"))
|
||||
if _is_valid_price(p):
|
||||
return p
|
||||
|
||||
# AggregateOffer: only explicit 'price', never lowPrice (= cheapest marketplace seller)
|
||||
for o in aggregate:
|
||||
raw = o.get("price")
|
||||
if raw is not None:
|
||||
p = _extract_price(raw)
|
||||
if _is_valid_price(p):
|
||||
return p
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _price_from_og(structured: dict) -> float | None:
|
||||
raw = _og(structured, "product:price:amount") or _og(structured, "og:price:amount")
|
||||
return _extract_price(raw) if raw else None
|
||||
|
||||
|
||||
# ── Stale/old-price detection ──────────────────────────────────────────────────
|
||||
|
||||
_STALE_TAGS = {"del", "s", "strike"}
|
||||
_STALE_CLASS_RE = re.compile(
|
||||
r"\b(old|was|before|original|compare|strike|crossed|regular|retail|initial|barr[eé]|ancien)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _is_stale_price(tag: Tag) -> bool:
|
||||
for parent in tag.parents:
|
||||
if not isinstance(parent, Tag):
|
||||
continue
|
||||
if parent.name in _STALE_TAGS:
|
||||
return True
|
||||
if _STALE_CLASS_RE.search(" ".join(parent.get("class") or [])):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Meta (BeautifulSoup fallback for OG when extruct misses) ──────────────────
|
||||
|
||||
def _meta(soup: BeautifulSoup, prop: str) -> str | None:
|
||||
tag = soup.find("meta", property=prop) or soup.find("meta", attrs={"name": prop})
|
||||
return tag["content"].strip() if tag and tag.get("content") else None # type: ignore
|
||||
|
||||
|
||||
# ── Microdata fallback (for sites where extruct misses itemprop="price") ──────
|
||||
|
||||
def _price_from_microdata_soup(soup: BeautifulSoup) -> float | None:
|
||||
for tag in soup.find_all(itemprop="price"):
|
||||
if not isinstance(tag, Tag) or _is_stale_price(tag):
|
||||
continue
|
||||
raw = tag.get("content") or tag.get_text(" ", strip=True)
|
||||
p = _extract_price(str(raw))
|
||||
if _is_valid_price(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
# ── Data attributes ───────────────────────────────────────────────────────────
|
||||
|
||||
_DATA_PRICE_ATTRS = [
|
||||
"data-price", "data-product-price", "data-current-price",
|
||||
"data-sale-price", "data-final-price", "data-offer-price",
|
||||
"data-selling-price", "data-discounted-price", "data-special-price",
|
||||
"data-amount", "data-price-amount", "data-variant-price",
|
||||
]
|
||||
|
||||
|
||||
def _price_from_data_attrs(soup: BeautifulSoup) -> float | None:
|
||||
for attr in _DATA_PRICE_ATTRS:
|
||||
for tag in soup.find_all(attrs={attr: True}):
|
||||
if not isinstance(tag, Tag) or _is_stale_price(tag):
|
||||
continue
|
||||
raw = str(tag[attr])
|
||||
p = _extract_price(raw)
|
||||
if _is_valid_price(p):
|
||||
return p
|
||||
# Shopify-style cents: pure integer ≥ 300 with no decimal → divide by 100
|
||||
if re.fullmatch(r"\d{3,7}", raw.strip()):
|
||||
candidate = int(raw.strip()) / 100
|
||||
if _is_valid_price(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
# ── Amazon buy-box ────────────────────────────────────────────────────────────
|
||||
|
||||
_AMAZON_SELECTORS = [
|
||||
".apexPriceToPay .a-offscreen",
|
||||
"#apex_offerDisplay_desktop .a-price:not(.a-text-strike) .a-offscreen",
|
||||
"#corePriceDisplay_desktop_feature_div .a-price:not(.a-text-strike) .a-offscreen",
|
||||
"#corePrice_desktop .a-price:not(.a-text-strike) .a-offscreen",
|
||||
"#priceblock_dealprice",
|
||||
"#priceblock_ourprice",
|
||||
"#price_inside_buybox",
|
||||
"#price",
|
||||
]
|
||||
|
||||
|
||||
def _price_from_amazon(soup: BeautifulSoup, url: str) -> float | None:
|
||||
if "amazon." not in (urlparse(url).hostname or ""):
|
||||
return None
|
||||
for sel in _AMAZON_SELECTORS:
|
||||
tag = soup.select_one(sel)
|
||||
if tag:
|
||||
p = _extract_price(tag.get_text(" ", strip=True))
|
||||
if _is_valid_price(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
# ── Platform-specific embedded JSON ───────────────────────────────────────────
|
||||
|
||||
def _deep_price(obj, depth: int = 0, visited: list | None = None) -> float | None:
|
||||
if visited is None:
|
||||
visited = []
|
||||
if depth > 5 or len(visited) > 200:
|
||||
return None
|
||||
visited.append(id(obj))
|
||||
if isinstance(obj, dict):
|
||||
for key in ("price", "currentPrice", "salePrice", "offerPrice", "finalPrice",
|
||||
"sellingPrice", "amount", "unitPrice", "prix"):
|
||||
val = obj.get(key)
|
||||
if val is not None:
|
||||
if isinstance(val, int) and 200 < val < 1_000_000:
|
||||
c = val / 100
|
||||
if _is_valid_price(c):
|
||||
return c
|
||||
p = _extract_price(val)
|
||||
if _is_valid_price(p):
|
||||
return p
|
||||
for v in list(obj.values()):
|
||||
p = _deep_price(v, depth + 1, visited)
|
||||
if p is not None:
|
||||
return p
|
||||
elif isinstance(obj, list):
|
||||
for item in obj[:5]:
|
||||
p = _deep_price(item, depth + 1, visited)
|
||||
if p is not None:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _price_from_platform_json(soup: BeautifulSoup) -> float | None:
|
||||
for tag in soup.find_all("script"):
|
||||
text = tag.string or ""
|
||||
if not text or len(text) > 200_000:
|
||||
continue
|
||||
script_type = tag.get("type", "")
|
||||
script_id = tag.get("id", "")
|
||||
if script_type == "application/ld+json":
|
||||
continue
|
||||
|
||||
if script_id == "__NEXT_DATA__":
|
||||
try:
|
||||
p = _deep_price(json.loads(text).get("props", {}).get("pageProps", {}))
|
||||
if _is_valid_price(p):
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if script_type == "application/json" and "product" in script_id.lower():
|
||||
try:
|
||||
data = json.loads(text)
|
||||
for key in ("price", "price_min"):
|
||||
val = data.get(key)
|
||||
if isinstance(val, int) and val > 0:
|
||||
return val / 100
|
||||
variants = data.get("variants", [])
|
||||
if variants and isinstance(variants[0], dict):
|
||||
val = variants[0].get("price")
|
||||
if isinstance(val, int) and val > 0:
|
||||
return val / 100
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if script_type == "text/x-magento-init":
|
||||
try:
|
||||
p = _deep_price(json.loads(text))
|
||||
if _is_valid_price(p):
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "dataLayer" in text and '"price"' in text:
|
||||
for m in re.finditer(r'"price"\s*:\s*["\']?([\d.,]+)["\']?', text):
|
||||
p = _extract_price(m.group(1))
|
||||
if _is_valid_price(p):
|
||||
return p
|
||||
|
||||
if '"price"' in text and len(text) < 50_000:
|
||||
for m in re.finditer(
|
||||
r'(?:var\s+\w+|window\.\w+|\w+\s*=)\s*=?\s*(\{[^;]{10,5000}\})',
|
||||
text, re.DOTALL
|
||||
):
|
||||
try:
|
||||
p = _deep_price(json.loads(m.group(1)))
|
||||
if _is_valid_price(p):
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── CSS heuristics ────────────────────────────────────────────────────────────
|
||||
|
||||
_PRICE_CLASS_RE = re.compile(
|
||||
r"\b(current[-_]?price|sale[-_]?price|offer[-_]?price|final[-_]?price|"
|
||||
r"selling[-_]?price|special[-_]?price|promo[-_]?price|discounted[-_]?price|"
|
||||
r"our[-_]?price|now[-_]?price|prix[-_]?actuel|prix[-_]?solde|prix[-_]?vente|"
|
||||
r"product[-_]?price(?![-_]?(old|was|before|original|strikethrough)))\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PRICE_ID_RE = re.compile(r"\b(our_price|current_price|sale_price|final_price)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _price_from_css_patterns(soup: BeautifulSoup) -> float | None:
|
||||
candidates: list[float] = []
|
||||
for tag in soup.select("ins .woocommerce-Price-amount, ins .amount"):
|
||||
p = _extract_price(tag.get_text(" ", strip=True))
|
||||
if _is_valid_price(p):
|
||||
candidates.append(p) # type: ignore
|
||||
for tag in soup.find_all(True):
|
||||
if not isinstance(tag, Tag):
|
||||
continue
|
||||
classes = " ".join(tag.get("class") or [])
|
||||
tag_id = tag.get("id", "")
|
||||
if not (_PRICE_CLASS_RE.search(classes) or _PRICE_ID_RE.search(tag_id)):
|
||||
continue
|
||||
if _is_stale_price(tag):
|
||||
continue
|
||||
p = _extract_price(tag.get_text(" ", strip=True))
|
||||
if _is_valid_price(p):
|
||||
candidates.append(p) # type: ignore
|
||||
if not candidates:
|
||||
return None
|
||||
freq = Counter(candidates)
|
||||
top_price, top_count = freq.most_common(1)[0]
|
||||
return top_price if top_count > 1 else min(candidates)
|
||||
|
||||
|
||||
# ── Title / description cleaning ──────────────────────────────────────────────
|
||||
|
||||
_TITLE_SUFFIX_RE = re.compile(
|
||||
r"\s*[\|:–—\-]\s*(?:Achat|Acheter|Shop|Store|Boutique|Commander|Buy|"
|
||||
r"livraison|free\s*ship|gratuit)[^|:–—\-]*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _clean_title(title: str, url: str) -> str:
|
||||
title = _strip_html(title)
|
||||
host = (urlparse(url).hostname or "").removeprefix("www.")
|
||||
root = host.split(".")[0]
|
||||
cleaned = re.split(rf"\s*[:|–—\-]\s*{re.escape(root)}", title, flags=re.IGNORECASE)[0]
|
||||
cleaned = _TITLE_SUFFIX_RE.sub("", cleaned)
|
||||
return cleaned.strip() or title
|
||||
|
||||
|
||||
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
"""Supprime les balises HTML résiduelles et décode les entités."""
|
||||
text = _HTML_TAG_RE.sub(" ", text)
|
||||
text = html_lib.unescape(text)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def _clean_description(desc: str | None) -> str | None:
|
||||
if not desc:
|
||||
return None
|
||||
desc = _strip_html(desc)
|
||||
return (desc[:497] + "…") if len(desc) > 500 else desc or None
|
||||
|
||||
|
||||
# ── Main parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _parse(html: str, url: str = "") -> ScrapeResult:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
# Structured data via extruct (JSON-LD + microdata + OpenGraph in one pass)
|
||||
structured = _get_structured(html, url)
|
||||
jld = _get_product_node(structured)
|
||||
|
||||
# Title
|
||||
raw_title = (
|
||||
(jld.get("name") if jld else None)
|
||||
or _og(structured, "og:title")
|
||||
or _meta(soup, "og:title")
|
||||
or _meta(soup, "twitter:title")
|
||||
or (soup.title.get_text(strip=True) if soup.title else None)
|
||||
)
|
||||
title = _strip_html(raw_title) if raw_title else None
|
||||
|
||||
# Description
|
||||
description = _clean_description(
|
||||
(jld.get("description") if jld else None)
|
||||
or _og(structured, "og:description")
|
||||
or _meta(soup, "og:description")
|
||||
or _meta(soup, "twitter:description")
|
||||
or _meta(soup, "description")
|
||||
)
|
||||
|
||||
# Image
|
||||
image: str | None = None
|
||||
if jld:
|
||||
img = jld.get("image")
|
||||
if isinstance(img, list):
|
||||
img = img[0] if img else None
|
||||
if isinstance(img, dict):
|
||||
img = img.get("url") or img.get("contentUrl")
|
||||
image = img if isinstance(img, str) else None
|
||||
image = (
|
||||
image
|
||||
or _og(structured, "og:image")
|
||||
or _meta(soup, "og:image")
|
||||
or _meta(soup, "twitter:image")
|
||||
)
|
||||
|
||||
# Price — lazy chain, stops at first valid result
|
||||
price = (
|
||||
_price_from_product_node(jld)
|
||||
or _price_from_og(structured)
|
||||
or _meta(soup, "product:price:amount") and _extract_price(_meta(soup, "product:price:amount"))
|
||||
or _price_from_microdata_soup(soup)
|
||||
or _price_from_data_attrs(soup)
|
||||
or _price_from_amazon(soup, url)
|
||||
or _price_from_platform_json(soup)
|
||||
or _price_from_css_patterns(soup)
|
||||
)
|
||||
if not _is_valid_price(price):
|
||||
price = None
|
||||
|
||||
if description and title and description.strip() == title.strip():
|
||||
description = None
|
||||
|
||||
return ScrapeResult(title=title, description=description, price=price, image=image)
|
||||
|
||||
|
||||
# ── Affiliate enrichment ──────────────────────────────────────────────────────
|
||||
|
||||
async def _enrich_with_affiliates(url: str, result: ScrapeResult) -> ScrapeResult:
|
||||
from shared.affiliates import amazon, awin, tradedoubler
|
||||
|
||||
host = urlparse(url).hostname or ""
|
||||
|
||||
if "amazon." in host:
|
||||
asin = amazon.extract_asin(url)
|
||||
if asin:
|
||||
data = await amazon.fetch_product(asin)
|
||||
if data:
|
||||
return ScrapeResult(
|
||||
title=data.get("title") or result.title,
|
||||
description=result.description,
|
||||
price=data.get("price") or result.price,
|
||||
image=data.get("image") or result.image,
|
||||
affiliate_url=data.get("affiliate_url"),
|
||||
)
|
||||
tagged = amazon.tag_url(url)
|
||||
if tagged:
|
||||
return result.model_copy(update={"affiliate_url": tagged})
|
||||
|
||||
affiliate_url = await tradedoubler.get_affiliate_url(url) or awin.get_affiliate_url(url)
|
||||
if affiliate_url:
|
||||
result = result.model_copy(update={"affiliate_url": affiliate_url})
|
||||
|
||||
if result.title:
|
||||
result = result.model_copy(update={"title": _clean_title(result.title, url)})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── HTML fetcher (FlareSolverr + fallback httpx) ──────────────────────────────
|
||||
|
||||
_FLARESOLVERR_URL = "http://kankwa-scraper:8191/v1"
|
||||
|
||||
|
||||
async def _fetch_html(url: str) -> str | None:
|
||||
"""
|
||||
Fetch via FlareSolverr (JS rendering + bypass Cloudflare).
|
||||
Fallback httpx si FlareSolverr est indisponible.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
_FLARESOLVERR_URL,
|
||||
json={"cmd": "request.get", "url": url, "maxTimeout": 45000},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("status") == "ok":
|
||||
html = data["solution"]["response"]
|
||||
logger.info("FlareSolverr OK %s (%d bytes)", url, len(html))
|
||||
return html
|
||||
logger.warning("FlareSolverr status=%s %s", data.get("status"), url)
|
||||
except Exception as e:
|
||||
logger.warning("FlareSolverr unavailable (%s), fallback httpx: %s", url, e)
|
||||
|
||||
# Fallback httpx — pour les sites sans anti-bot ou si FlareSolverr est down.
|
||||
# Redirections suivies manuellement pour revalider chaque cible (anti-SSRF par redirect).
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=False, timeout=15) as client:
|
||||
current = url
|
||||
for _ in range(5):
|
||||
resp = await client.get(current, headers=HEADERS)
|
||||
if resp.is_redirect:
|
||||
location = resp.headers.get("location")
|
||||
if not location:
|
||||
break
|
||||
current = str(resp.url.join(location))
|
||||
_validate_url(current) # lève si la redirection pointe vers l'interne
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
logger.info("httpx fallback OK %s", url)
|
||||
return resp.text
|
||||
logger.warning("httpx fallback : trop de redirections %s", url)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("httpx fallback failed %s: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/parse-html", response_model=ScrapeResult, dependencies=[Depends(get_current_user)])
|
||||
async def parse_html(body: ParseRequest):
|
||||
_validate_url(body.url)
|
||||
result = _parse(body.html, body.url)
|
||||
result = await _enrich_with_affiliates(body.url, result)
|
||||
logger.info("parse-html %s → title=%s price=%s affiliate=%s", body.url, result.title, result.price, bool(result.affiliate_url))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/affiliates/programs", dependencies=[Depends(get_current_user)])
|
||||
async def get_affiliate_programs():
|
||||
from shared.affiliates import tradedoubler
|
||||
programs = await tradedoubler.list_programs()
|
||||
return {"programs": programs, "count": len(programs)}
|
||||
|
||||
|
||||
@router.get("/scrape", response_model=ScrapeResult, dependencies=[Depends(get_current_user)])
|
||||
async def scrape_url(url: str = Query(...)):
|
||||
_validate_url(url)
|
||||
html = await _fetch_html(url)
|
||||
if not html:
|
||||
return ScrapeResult()
|
||||
result = _parse(html, url)
|
||||
result = await _enrich_with_affiliates(url, result)
|
||||
logger.info("scrape %s → title=%s price=%s image=%s affiliate=%s", url, result.title, result.price, bool(result.image), bool(result.affiliate_url))
|
||||
return result
|
||||
|
||||
|
||||
# ── Public share URL resolver ─────────────────────────────────────────────────
|
||||
|
||||
_SHARE_URL_MAP: dict[str, tuple] = {
|
||||
"kdo": (KdoList, "/share/{token}"),
|
||||
"hub": (Event, "/hub/p/{token}"),
|
||||
"kal": (KalPoll, "/share/kal/{token}"),
|
||||
"kwiz": (KwizPoll, "/share/kwiz/{token}"),
|
||||
"kount": (KountGroup, "/kount/{token}"),
|
||||
"kontrib": (KontribList, "/kontrib/{token}"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/share-url/{resource_type}/{resource_id}")
|
||||
async def get_public_share_url(resource_type: str, resource_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Retourne l'URL publique d'une ressource à partir de son type et son ID (sans auth)."""
|
||||
import uuid as _uuid
|
||||
entry = _SHARE_URL_MAP.get(resource_type)
|
||||
if not entry:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Type inconnu")
|
||||
try:
|
||||
rid = _uuid.UUID(resource_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ID invalide")
|
||||
model_cls, url_tpl = entry
|
||||
row = await db.scalar(select(model_cls.share_token).where(model_cls.id == rid))
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ressource introuvable")
|
||||
return {"url": url_tpl.format(token=row)}
|
||||
52
api/routers/sitemap.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from fastapi import APIRouter
|
||||
from fastapi.responses import Response
|
||||
|
||||
from api.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
STATIC_PAGES = [
|
||||
{"loc": "/", "priority": "1.0", "changefreq": "weekly"},
|
||||
{"loc": "/kdo", "priority": "0.8", "changefreq": "weekly"},
|
||||
{"loc": "/kontrib", "priority": "0.8", "changefreq": "weekly"},
|
||||
{"loc": "/kount", "priority": "0.8", "changefreq": "weekly"},
|
||||
{"loc": "/kal", "priority": "0.8", "changefreq": "weekly"},
|
||||
{"loc": "/hub", "priority": "0.8", "changefreq": "weekly"},
|
||||
{"loc": "/legal", "priority": "0.3", "changefreq": "monthly"},
|
||||
{"loc": "/privacy", "priority": "0.3", "changefreq": "monthly"},
|
||||
{"loc": "/terms", "priority": "0.3", "changefreq": "monthly"},
|
||||
]
|
||||
|
||||
# Miroir de frontend/src/shared/config/articles.ts — ajouter ici chaque nouvel article
|
||||
BLOG_ARTICLES = [
|
||||
"/organiser-week-end-entre-amis",
|
||||
"/sondage-disponibilite-gratuit-sans-pub",
|
||||
"/cadeau-commun-partage-frais-sans-commission",
|
||||
"/qui-apporte-quoi-fete-ecole",
|
||||
]
|
||||
|
||||
|
||||
@router.get("/sitemap.xml", include_in_schema=False)
|
||||
async def sitemap():
|
||||
base = settings.frontend_url.rstrip("/")
|
||||
|
||||
blog_pages = [
|
||||
{"loc": slug, "priority": "0.9", "changefreq": "monthly"}
|
||||
for slug in BLOG_ARTICLES
|
||||
]
|
||||
|
||||
urls = "\n".join(
|
||||
f""" <url>
|
||||
<loc>{base}{p["loc"]}</loc>
|
||||
<changefreq>{p["changefreq"]}</changefreq>
|
||||
<priority>{p["priority"]}</priority>
|
||||
</url>"""
|
||||
for p in STATIC_PAGES + blog_pages
|
||||
)
|
||||
|
||||
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
{urls}
|
||||
</urlset>"""
|
||||
|
||||
return Response(content=xml, media_type="application/xml")
|
||||
56
docker-compose.yml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: kankwa-db
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_HOST_AUTH_METHOD: scram-sha-256
|
||||
command: postgres -c password_encryption=scram-sha-256
|
||||
volumes:
|
||||
- /srv/user-data/kankwa:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
|
||||
interval: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ai-net
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: kankwa-api
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
environment:
|
||||
PYTHONPATH: /app
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ai-net
|
||||
|
||||
scraper:
|
||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||
container_name: kankwa-scraper
|
||||
environment:
|
||||
LOG_LEVEL: info
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ai-net
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
container_name: kankwa-frontend
|
||||
ports:
|
||||
- "127.0.0.1:3001:3001"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ai-net
|
||||
|
||||
networks:
|
||||
ai-net:
|
||||
external: true
|
||||
11
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
RUN npm install -g serve
|
||||
COPY --from=builder /app/dist /srv
|
||||
CMD ["serve", "-s", "/srv", "-l", "3001"]
|
||||
63
frontend/index.html
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kankwa — Organisez vos moments entre proches</title>
|
||||
<meta name="description" content="Listes de cadeaux, disponibilités, partage de frais, sondages, qui apporte quoi — tout au même endroit. Vos invités participent sans créer de compte. Sans pub." />
|
||||
<meta name="theme-color" content="#c77548" />
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Kankwa" />
|
||||
<meta property="og:locale" content="fr_FR" />
|
||||
<meta property="og:url" content="https://kankwa.fr/" />
|
||||
<meta property="og:title" content="Kankwa — Organisez vos moments entre proches" />
|
||||
<meta property="og:description" content="Listes de cadeaux, disponibilités, partage de frais, sondages, qui apporte quoi — tout au même endroit. Vos invités participent sans créer de compte. Sans pub." />
|
||||
<meta property="og:image" content="https://kankwa.fr/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="Kankwa — Organisez vos moments entre proches" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Kankwa — Organisez vos moments entre proches" />
|
||||
<meta name="twitter:description" content="Listes de cadeaux, disponibilités, partage de frais, sondages, qui apporte quoi — tout au même endroit. Vos invités participent sans créer de compte. Sans pub." />
|
||||
<meta name="twitter:image" content="https://kankwa.fr/og-image.png" />
|
||||
<meta name="twitter:image:alt" content="Kankwa — Organisez vos moments entre proches" />
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-180x180.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "Kankwa",
|
||||
"url": "https://kankwa.fr",
|
||||
"description": "Listes de cadeaux, disponibilités, partage de frais, sondages, qui apporte quoi — tout au même endroit.",
|
||||
"inLanguage": "fr-FR",
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "Kankwa",
|
||||
"url": "https://kankwa.fr",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https://kankwa.fr/favicon-512x512.png",
|
||||
"width": 512,
|
||||
"height": 512
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4363
frontend/package-lock.json
generated
Normal file
29
frontend/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "kankwa-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "^3.1.1",
|
||||
"@stripe/stripe-js": "^5.5.0",
|
||||
"axios": "^1.7.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-qr-code": "^2.0.15",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.14",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
frontend/public/_logo_backup_2026-06-14/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 737 B |
BIN
frontend/public/_logo_backup_2026-06-14/favicon-180x180.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
frontend/public/_logo_backup_2026-06-14/favicon-192x192.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
frontend/public/_logo_backup_2026-06-14/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/public/_logo_backup_2026-06-14/favicon-48x48.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/public/_logo_backup_2026-06-14/favicon-512x512.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/public/_logo_backup_2026-06-14/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
31
frontend/public/_logo_backup_2026-06-14/favicon.svg
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;">
|
||||
<!-- Logo mark -->
|
||||
<g transform="matrix(2.087768,0,0,2.087768,-351.618157,-214.429913)">
|
||||
<g transform="matrix(0.719623,0,0,0.719623,83.082932,65.839659)">
|
||||
<g transform="matrix(1,0,0,1,0,-13.229727)">
|
||||
<g transform="matrix(1,0,0,1,0,-15.710301)">
|
||||
<path d="M340.213,156.506C323.373,195.688 286.305,233.972 195.428,262.71" fill="none" stroke="rgb(199,117,72)" stroke-width="43.37"/>
|
||||
</g>
|
||||
<path d="M147.749,101.07C181.326,136.721 190.496,341.287 145.269,368.64" fill="none" stroke="rgb(199,117,72)" stroke-width="43.37"/>
|
||||
<path d="M195.428,247C218.994,258.294 330.9,349.868 422.761,356.659" fill="none" stroke="rgb(199,117,72)" stroke-width="43.37"/>
|
||||
</g>
|
||||
<g>
|
||||
<g transform="matrix(1.550359,0,0,1.550359,-137.680124,-147.837111)">
|
||||
<circle cx="214" cy="247" r="24" fill="none" stroke="rgb(26,31,46)" stroke-width="15.46" stroke-linecap="butt"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,18.753332,-246.710016)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,-171.22953,-288.916216)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,-169.575814,-21.346071)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,96.340615,-31.330051)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/blog/hub-weekend-biarritz.jpg
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
frontend/public/blog/kal-dates-privilegiees.jpg
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
frontend/public/blog/kdo-cadeau-lea.jpg
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
frontend/public/blog/kdo-collecte-delonghi.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
frontend/public/blog/kontrib-buffet-kermesse.jpg
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
frontend/public/blog/kount-remboursements.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
frontend/public/blog/kount-vacances-biarritz.jpg
Normal file
|
After Width: | Height: | Size: 206 KiB |
12
frontend/public/email-icons/hub.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#C77548" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="4" y1="6" x2="18" y2="4" stroke-linecap="round"/>
|
||||
<line x1="4" y1="6" x2="3" y2="17" stroke-linecap="round"/>
|
||||
<line x1="4" y1="6" x2="21" y2="15" stroke-linecap="round"/>
|
||||
<line x1="18" y1="4" x2="21" y2="15" stroke-linecap="round"/>
|
||||
<line x1="18" y1="4" x2="9" y2="21" stroke-linecap="round"/>
|
||||
<line x1="21" y1="15" x2="9" y2="21" stroke-linecap="round"/>
|
||||
<line x1="9" y1="21" x2="3" y2="17" stroke-linecap="round"/>
|
||||
<circle cx="4" cy="6" r="1.5" fill="#C77548" stroke="none"/>
|
||||
<circle cx="18" cy="4" r="1.5" fill="#C77548" stroke="none"/>
|
||||
<circle cx="21" cy="15" r="1.5" fill="#C77548" stroke="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 752 B |
7
frontend/public/email-icons/kal.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#C77548" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="4" width="18" height="17" rx="2"/>
|
||||
<path d="M3 9h18"/>
|
||||
<path d="M8 2v4M16 2v4"/>
|
||||
<path d="M7 13h2M11 13h2M15 13h2"/>
|
||||
<path d="M7 17h2M11 17h2M15 17h2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 364 B |
3
frontend/public/email-icons/kdo.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#C77548" stroke-width="1.8" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 12v10H4V12M22 7H2v5h20V7zM12 22V7M12 7H7.5a2.5 2.5 0 010-5C11 2 12 7 12 7zM12 7h4.5a2.5 2.5 0 000-5C13 2 12 7 12 7z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 318 B |
8
frontend/public/email-icons/kontrib.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#C77548" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="4.5" cy="7" r="1.5" fill="#C77548" stroke="none"/>
|
||||
<circle cx="4.5" cy="12" r="1.5" fill="#C77548" stroke="none"/>
|
||||
<circle cx="4.5" cy="17" r="1.5" fill="#C77548" stroke="none"/>
|
||||
<line x1="9" y1="7" x2="20" y2="7" stroke-linecap="round"/>
|
||||
<line x1="9" y1="12" x2="20" y2="12" stroke-linecap="round"/>
|
||||
<line x1="9" y1="17" x2="20" y2="17" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 514 B |
6
frontend/public/email-icons/kount.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="32" height="22" viewBox="0 0 32 22" fill="none" stroke="#C77548" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2 6a2 2 0 0 1 2-2h24a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6z"/>
|
||||
<path d="M2 8.5q2 0 2-2M30 8.5q-2 0-2-2M2 13.5q2 0 2 2M30 13.5q-2 0-2 2"/>
|
||||
<ellipse cx="16" cy="11" rx="3.2" ry="3.5"/>
|
||||
<path d="M16 8.5v5M14.5 9.5a1.5 1 0 0 1 3 0c0 .8-.9 1-1.5 1.5s-1.5.7-1.5 1.5a1.5 1 0 0 0 3 0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
3
frontend/public/email-icons/kwiz.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#C77548" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 20V10M12 20V4M6 20v-6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 203 B |
30
frontend/public/email-icons/logo.svg
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<svg width="32" height="32" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;">
|
||||
<g transform="matrix(2.087768,0,0,2.087768,-351.618157,-214.429913)">
|
||||
<g transform="matrix(0.719623,0,0,0.719623,83.082932,65.839659)">
|
||||
<g transform="matrix(1,0,0,1,0,-13.229727)">
|
||||
<g transform="matrix(1,0,0,1,0,-15.710301)">
|
||||
<path d="M340.213,156.506C323.373,195.688 286.305,233.972 195.428,262.71" fill="none" stroke="rgb(199,117,72)" stroke-width="43.37" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<path d="M147.749,101.07C181.326,136.721 190.496,341.287 145.269,368.64" fill="none" stroke="rgb(199,117,72)" stroke-width="43.37" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M195.428,247C218.994,258.294 330.9,349.868 422.761,356.659" fill="none" stroke="rgb(199,117,72)" stroke-width="43.37" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<g>
|
||||
<g transform="matrix(1.550359,0,0,1.550359,-137.680124,-147.837111)">
|
||||
<circle cx="214" cy="247" r="24" fill="none" stroke="rgb(26,31,46)" stroke-width="15.46" stroke-linecap="butt" stroke-miterlimit="2"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,18.753332,-246.710016)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,-171.22953,-288.916216)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,-169.575814,-21.346071)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,96.340615,-31.330051)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 511 B |
BIN
frontend/public/favicon-180x180.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
frontend/public/favicon-192x192.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
frontend/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/public/favicon-48x48.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend/public/favicon-512x512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
34
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;">
|
||||
<rect x="0" y="0" width="512" height="512" rx="112" ry="112" fill="#ffffff"/>
|
||||
<g transform="translate(106.000,106.000) scale(0.58594)">
|
||||
<!-- Logo mark -->
|
||||
<g transform="matrix(2.087768,0,0,2.087768,-351.618157,-214.429913)">
|
||||
<g transform="matrix(0.719623,0,0,0.719623,83.082932,65.839659)">
|
||||
<g transform="matrix(1,0,0,1,0,-13.229727)">
|
||||
<g transform="matrix(1,0,0,1,0,-15.710301)">
|
||||
<path d="M340.213,156.506C323.373,195.688 286.305,233.972 195.428,262.71" fill="none" stroke="rgb(199,117,72)" stroke-width="43.37"/>
|
||||
</g>
|
||||
<path d="M147.749,101.07C181.326,136.721 190.496,341.287 145.269,368.64" fill="none" stroke="rgb(199,117,72)" stroke-width="43.37"/>
|
||||
<path d="M195.428,247C218.994,258.294 330.9,349.868 422.761,356.659" fill="none" stroke="rgb(199,117,72)" stroke-width="43.37"/>
|
||||
</g>
|
||||
<g>
|
||||
<g transform="matrix(1.550359,0,0,1.550359,-137.680124,-147.837111)">
|
||||
<circle cx="214" cy="247" r="24" fill="none" stroke="rgb(26,31,46)" stroke-width="15.46" stroke-linecap="butt"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,18.753332,-246.710016)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,-171.22953,-288.916216)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,-169.575814,-21.346071)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,96.340615,-31.330051)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/og-image.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
45
frontend/public/og-image.svg
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg" style="font-family: system-ui, -apple-system, sans-serif;">
|
||||
|
||||
<!-- Fond blanc -->
|
||||
<rect width="1200" height="630" fill="#ffffff"/>
|
||||
|
||||
<!-- Logo mark à gauche, couleurs fidèles au favicon (terracotta + sombre sur blanc) -->
|
||||
<g transform="translate(115, 115) scale(0.78)">
|
||||
<g transform="matrix(2.087768,0,0,2.087768,-351.618157,-214.429913)">
|
||||
<g transform="matrix(0.719623,0,0,0.719623,83.082932,65.839659)">
|
||||
<g transform="matrix(1,0,0,1,0,-13.229727)">
|
||||
<g transform="matrix(1,0,0,1,0,-15.710301)">
|
||||
<path d="M340.213,156.506C323.373,195.688 286.305,233.972 195.428,262.71" fill="none" stroke="rgb(199,117,72)" stroke-width="43.37" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<path d="M147.749,101.07C181.326,136.721 190.496,341.287 145.269,368.64" fill="none" stroke="rgb(199,117,72)" stroke-width="43.37" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M195.428,247C218.994,258.294 330.9,349.868 422.761,356.659" fill="none" stroke="rgb(199,117,72)" stroke-width="43.37" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<g>
|
||||
<g transform="matrix(1.550359,0,0,1.550359,-137.680124,-147.837111)">
|
||||
<circle cx="214" cy="247" r="24" fill="none" stroke="rgb(26,31,46)" stroke-width="15.46"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,18.753332,-246.710016)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,-171.22953,-288.916216)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,-169.575814,-21.346071)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,96.340615,-31.330051)">
|
||||
<circle cx="214" cy="247" r="24" fill="rgb(26,31,46)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Texte à droite, centré verticalement -->
|
||||
<text x="560" y="258" font-size="72" font-weight="700" fill="rgb(26,31,46)" letter-spacing="-1">kankwa</text>
|
||||
<text x="562" y="322" font-size="26" fill="rgba(26,31,46,0.65)" letter-spacing="0.3">Organisez vos moments entre proches</text>
|
||||
<rect x="562" y="352" width="48" height="3" rx="1.5" fill="rgb(199,117,72)"/>
|
||||
<text x="562" y="394" font-size="22" fill="rgba(26,31,46,0.5)">Cadeaux · Frais partagés</text>
|
||||
<text x="562" y="422" font-size="22" fill="rgba(26,31,46,0.5)">Sondages · Disponibilités</text>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
28
frontend/public/og.svg
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="512px" height="512px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;">
|
||||
<path d="M511.999,102.4L511.999,409.599C511.999,466.115 466.115,511.999 409.599,511.999L102.4,511.999C45.884,511.999 0,466.115 0,409.599L0,102.4C0,45.884 45.884,0 102.4,0L409.599,0C466.115,0 511.999,45.884 511.999,102.4Z" style="fill:rgb(224,152,112);"/>
|
||||
<g transform="matrix(1.209398,0,0,1.209398,-75.487535,-12.033655)">
|
||||
<g>
|
||||
<path d="M340.213,156.506C315.788,190.185 270.678,225.918 186.886,247" style="fill:none;fill-rule:nonzero;stroke:rgb(26,31,46);stroke-width:43.37px;"/>
|
||||
<path d="M140.308,87.84C197.226,164.503 174.067,345.745 140.308,355.411" style="fill:none;fill-rule:nonzero;stroke:rgb(26,31,46);stroke-width:43.37px;"/>
|
||||
<path d="M185.506,247C247.92,267.625 351.14,306.947 407.878,355.411" style="fill:none;fill-rule:nonzero;stroke:rgb(26,31,46);stroke-width:43.37px;"/>
|
||||
</g>
|
||||
<g>
|
||||
<g transform="matrix(1.859856,0,0,1.859856,-211.123528,-212.384392)">
|
||||
<circle cx="214" cy="247" r="24" style="fill:rgb(224,152,112);stroke:rgb(26,31,46);stroke-width:21.93px;stroke-linecap:butt;stroke-miterlimit:2;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,13.792184,-220.250562)">
|
||||
<circle cx="214" cy="247" r="24" style="fill:rgb(26,31,46);"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,-186.112973,-288.916216)">
|
||||
<circle cx="214" cy="247" r="24" style="fill:rgb(26,31,46);"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,-186.112973,-21.346071)">
|
||||
<circle cx="214" cy="247" r="24" style="fill:rgb(26,31,46);"/>
|
||||
</g>
|
||||
<g transform="matrix(1.52533,0,0,1.52533,81.457172,-21.346071)">
|
||||
<circle cx="214" cy="247" r="24" style="fill:rgb(26,31,46);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
15
frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Bloquer les pages privées et utilitaires
|
||||
Disallow: /login
|
||||
Disallow: /register
|
||||
Disallow: /magic
|
||||
Disallow: /verify-email
|
||||
Disallow: /set-password
|
||||
Disallow: /cancel/
|
||||
Disallow: /join
|
||||
Disallow: /compte
|
||||
Disallow: /admin/
|
||||
|
||||
Sitemap: https://kankwa.fr/sitemap.xml
|
||||
13
frontend/public/site.webmanifest
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "Kankwa",
|
||||
"short_name": "Kankwa",
|
||||
"description": "Une seule appli pour tout organiser entre proches.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#c77548",
|
||||
"icons": [
|
||||
{ "src": "/favicon-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/favicon-512x512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
38
frontend/public/sitemap.xml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://kankwa.fr/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://kankwa.fr/organiser-week-end-entre-amis</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://kankwa.fr/alternative-doodle-gratuite-sans-compte</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://kankwa.fr/cadeau-commun-partage-frais-sans-commission</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://kankwa.fr/legal</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://kankwa.fr/privacy</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://kankwa.fr/terms</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
123
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { lazy, Suspense } from 'react'
|
||||
import { BrowserRouter, Route, Routes, useLocation, Navigate } from 'react-router-dom'
|
||||
import { AuthGuard, CookieBanner, Footer, Navbar, Spinner } from '@shared/components'
|
||||
import { AuthProvider, useAuth } from '@shared/hooks/useAuth'
|
||||
import { services } from '@shared/config/services'
|
||||
|
||||
const AdminApp = lazy(() => import('@services/admin/pages/AdminApp'))
|
||||
const Landing = lazy(() => import('@services/kdo/pages/Landing'))
|
||||
const Login = lazy(() => import('@shared/pages/Login'))
|
||||
const Register = lazy(() => import('@shared/pages/Register'))
|
||||
const VerifyEmail = lazy(() => import('@shared/pages/VerifyEmail'))
|
||||
const MagicVerify = lazy(() => import('@services/kdo/pages/MagicVerify'))
|
||||
const SetPassword = lazy(() => import('@services/kdo/pages/SetPassword'))
|
||||
const ListPublic = lazy(() => import('@services/kdo/pages/ListPublic'))
|
||||
const Cancel = lazy(() => import('@services/kdo/pages/Cancel'))
|
||||
const AcceptOwnership = lazy(() => import('@shared/pages/AcceptOwnership'))
|
||||
const HubPublic = lazy(() => import('@services/hub/pages/HubPublic'))
|
||||
const BringPublic = lazy(() => import('@services/kontrib/pages/BringPublic'))
|
||||
const GroupPublic = lazy(() => import('@services/kount/pages/GroupPublic'))
|
||||
const PollPublic = lazy(() => import('@services/kal/pages/PollPublic'))
|
||||
const KwizPollPublic = lazy(() => import('@services/kwiz/pages/PollPublic'))
|
||||
const Legal = lazy(() => import('@shared/pages/Legal'))
|
||||
const Privacy = lazy(() => import('@shared/pages/Privacy'))
|
||||
const Terms = lazy(() => import('@shared/pages/Terms'))
|
||||
const NotFound = lazy(() => import('@shared/pages/NotFound'))
|
||||
const Account = lazy(() => import('@shared/pages/Account'))
|
||||
const ArticleWeekend = lazy(() => import('@shared/pages/blog/ArticleWeekend'))
|
||||
const ArticleDoodle = lazy(() => import('@shared/pages/blog/ArticleDoodle'))
|
||||
const ArticleCadeauFrais = lazy(() => import('@shared/pages/blog/ArticleCadeauFrais'))
|
||||
const ArticleKontrib = lazy(() => import('@shared/pages/blog/ArticleKontrib'))
|
||||
const Blog = lazy(() => import('@shared/pages/blog/Blog'))
|
||||
|
||||
const PUBLIC_PATHS = ['/', '/magic', '/login', '/register',
|
||||
'/share', '/cancel', '/event', '/join', '/set-password', '/legal', '/privacy', '/terms',
|
||||
'/hub', '/kdo', '/kontrib', '/kount', '/kal', '/kwiz', '/blog',
|
||||
'/organiser-week-end-entre-amis', '/sondage-disponibilite-gratuit-sans-pub',
|
||||
'/cadeau-commun-partage-frais-sans-commission', '/qui-apporte-quoi-fete-ecole',
|
||||
'/alternative-doodle-gratuite-sans-pub', '/alternative-doodle-gratuite-sans-compte']
|
||||
// /share/kal/:token est géré via /share (prefix match)
|
||||
|
||||
const PageSpinner = () => (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Spinner className="w-6 h-6" />
|
||||
</div>
|
||||
)
|
||||
|
||||
function AppRoutes() {
|
||||
const location = useLocation()
|
||||
const { user, isLoading } = useAuth()
|
||||
const background = (location.state as any)?.background
|
||||
const isAuth = ['/login', '/register'].includes(location.pathname)
|
||||
|
||||
const isPublic = PUBLIC_PATHS.some(p => location.pathname === p || location.pathname.startsWith(p + '/'))
|
||||
if (!isLoading && user && !user.email_verified && !isPublic) {
|
||||
return <Navigate to="/verify-email" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
<Navbar />
|
||||
<div className="flex-1">
|
||||
<Suspense fallback={<PageSpinner />}>
|
||||
<Routes location={background ?? (isAuth ? { pathname: '/' } : location)}>
|
||||
<Route path="/" element={<Landing />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/magic" element={<MagicVerify />} />
|
||||
<Route path="/set-password" element={<SetPassword />} />
|
||||
<Route path="/share/:shareToken" element={<ListPublic />} />
|
||||
<Route path="/cancel/:cancelToken" element={<Cancel />} />
|
||||
<Route path="/hub/p/:shareToken" element={<HubPublic />} />
|
||||
<Route path="/kontrib/:shareToken" element={<BringPublic />} />
|
||||
<Route path="/kount/:shareToken" element={<GroupPublic />} />
|
||||
<Route path="/share/kal/:shareToken" element={<PollPublic />} />
|
||||
<Route path="/share/kwiz/:shareToken" element={<KwizPollPublic />} />
|
||||
<Route path="/join" element={<AcceptOwnership />} />
|
||||
<Route path="/legal" element={<Legal />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
<Route path="/terms" element={<Terms />} />
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
<Route path="/organiser-week-end-entre-amis" element={<ArticleWeekend />} />
|
||||
<Route path="/sondage-disponibilite-gratuit-sans-pub" element={<ArticleDoodle />} />
|
||||
{/* Redirects — anciens slugs indexés par Google */}
|
||||
<Route path="/alternative-doodle-gratuite-sans-pub" element={<Navigate to="/sondage-disponibilite-gratuit-sans-pub" replace />} />
|
||||
<Route path="/alternative-doodle-gratuite-sans-compte" element={<Navigate to="/sondage-disponibilite-gratuit-sans-pub" replace />} />
|
||||
<Route path="/cadeau-commun-partage-frais-sans-commission" element={<ArticleCadeauFrais />} />
|
||||
<Route path="/qui-apporte-quoi-fete-ecole" element={<ArticleKontrib />} />
|
||||
<Route path="/compte" element={<AuthGuard><Account /></AuthGuard>} />
|
||||
{services.flatMap(s =>
|
||||
s.routes.map((r, i) => (
|
||||
<Route key={`${s.id}-${i}`} path={r.path} element={r.element} />
|
||||
))
|
||||
)}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
|
||||
{isAuth && (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
</Routes>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
<Footer />
|
||||
<CookieBanner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<PageSpinner />}>
|
||||
<Routes>
|
||||
<Route path="/admin/*" element={<AdminApp />} />
|
||||
<Route path="*" element={<AppRoutes />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
202
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--z-navbar: 20;
|
||||
--z-sticky: 10;
|
||||
--z-modal: 50;
|
||||
--z-tooltip: 30;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Inputs */
|
||||
.input {
|
||||
@apply w-full min-w-0 max-w-full border border-gray-300 rounded-component px-3 py-2 text-base sm:text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors;
|
||||
}
|
||||
|
||||
/* iOS Safari : bloquer le zoom au focus (font-size < 16px déclenche le zoom) */
|
||||
@media (max-width: 639px) {
|
||||
input, textarea, select {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* iOS Safari : forcer nos styles sur le date input */
|
||||
input[type="date"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Buttons — style only, no size */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center bg-primary-600 text-white rounded-component text-sm font-medium hover:bg-primary-700 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center border border-gray-300 text-gray-600 rounded-component text-sm font-medium hover:bg-gray-50 active:scale-95 disabled:opacity-50 transition-all;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center justify-center bg-danger-600 text-white rounded-component text-sm font-medium hover:bg-danger-700 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all;
|
||||
}
|
||||
|
||||
/* Default size — only when btn-sm is NOT present */
|
||||
.btn-primary:not(.btn-sm),
|
||||
.btn-secondary:not(.btn-sm),
|
||||
.btn-danger:not(.btn-sm) {
|
||||
@apply px-4 py-2;
|
||||
}
|
||||
|
||||
/* Small modifier — h-8 forces identical height for <a>, <button>, icon buttons */
|
||||
.btn-sm {
|
||||
@apply h-8 inline-flex items-center justify-center px-3 text-sm rounded-component font-medium transition-all;
|
||||
}
|
||||
|
||||
/* Bouton × visible uniquement au survol souris (pas touch) */
|
||||
.delete-btn-hover {
|
||||
@apply opacity-0 transition-opacity duration-150;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.group:hover .delete-btn-hover {
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
|
||||
/* SwipeToDelete : le wrapper fournit la bordure et les coins.
|
||||
Le card-item à l'intérieur perd son bord droit et ses coins droits
|
||||
pour une jonction nette avec le bouton Supprimer. */
|
||||
.swipe-slide .card-item {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
@apply bg-white border border-gray-200 rounded-card p-5 shadow-sm;
|
||||
}
|
||||
|
||||
.card-item {
|
||||
@apply bg-white border border-gray-200 rounded-card p-4;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@apply font-semibold text-gray-900 truncate;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
@apply flex items-center gap-2 mt-0.5 flex-wrap text-sm text-gray-500;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
@apply flex items-center gap-2 pt-1;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
@apply flex gap-2 mt-1;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert-error {
|
||||
@apply text-sm text-danger-600 bg-danger-50 border border-danger-100 p-3 rounded-component break-all;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
@apply text-sm text-success-700 bg-success-50 border border-success-100 p-3 rounded-component break-all;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
@apply text-sm text-info-700 bg-info-50 border border-info-100 p-3 rounded-component break-all;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
@apply text-xs px-2 py-0.5 rounded-full font-medium;
|
||||
}
|
||||
|
||||
.badge-indigo {
|
||||
@apply badge bg-info-50 text-info-700;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
@apply badge bg-success-100 text-success-700;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply badge bg-gray-100 text-gray-500;
|
||||
}
|
||||
|
||||
/* Page layout */
|
||||
.page {
|
||||
@apply w-full px-4 sm:px-6 pb-6 animate-fadein;
|
||||
}
|
||||
|
||||
.page-sm {
|
||||
@apply max-w-sm mx-auto px-4 py-16;
|
||||
}
|
||||
|
||||
/* Layout : contenu principal + panneau latéral droit */
|
||||
.layout-main-aside {
|
||||
@apply flex gap-6 items-start;
|
||||
}
|
||||
|
||||
.layout-main-aside > .main {
|
||||
@apply flex-1 min-w-0;
|
||||
}
|
||||
|
||||
.layout-main-aside > .aside {
|
||||
@apply w-80 shrink-0 sticky top-6;
|
||||
}
|
||||
|
||||
/* Layout 3 colonnes pour les pages de service (actif à partir de xl = 1280px) */
|
||||
.layout-service {
|
||||
@apply flex flex-col gap-4 xl:flex-row xl:items-start;
|
||||
}
|
||||
|
||||
.layout-service > .service-left {
|
||||
@apply flex flex-col gap-4 order-2 xl:order-1 xl:w-64 xl:shrink-0 xl:sticky xl:z-10;
|
||||
top: var(--page-header-bottom, 188px);
|
||||
}
|
||||
|
||||
.layout-service > .service-main {
|
||||
@apply order-1 xl:order-2 xl:flex-1 xl:min-w-0;
|
||||
}
|
||||
|
||||
.layout-service > .service-right {
|
||||
@apply flex flex-col gap-4 order-3 xl:w-64 xl:shrink-0 xl:sticky xl:z-10;
|
||||
top: var(--page-header-bottom, 188px);
|
||||
}
|
||||
|
||||
/* Skeleton shimmer */
|
||||
.skeleton {
|
||||
@apply bg-gray-100 rounded animate-shimmer;
|
||||
background: linear-gradient(90deg, #F0EDE7 25%, #E5E3DD 50%, #F0EDE7 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@keyframes fadein {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from { background-position: 200% 0; }
|
||||
to { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.animate-fadein {
|
||||
animation: fadein 0.2s ease-out both;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 1.5s infinite linear;
|
||||
}
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
119
frontend/src/services/admin/api/admin.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import axios from 'axios'
|
||||
|
||||
const TOKEN_KEY = 'kankwa_admin_token'
|
||||
|
||||
export const adminToken = {
|
||||
get: () => sessionStorage.getItem(TOKEN_KEY),
|
||||
set: (t: string) => sessionStorage.setItem(TOKEN_KEY, t),
|
||||
clear: () => sessionStorage.removeItem(TOKEN_KEY),
|
||||
}
|
||||
|
||||
const api = axios.create({ baseURL: '/api/admin' })
|
||||
api.interceptors.request.use(cfg => {
|
||||
const t = adminToken.get()
|
||||
if (t) cfg.headers['X-Admin-Token'] = t
|
||||
return cfg
|
||||
})
|
||||
|
||||
export async function verifyAdminToken(): Promise<boolean> {
|
||||
try { await api.get('/verify'); return true } catch { return false }
|
||||
}
|
||||
|
||||
export interface ServiceRegistryEntry { key: string; label: string }
|
||||
|
||||
export interface AdminStats {
|
||||
users: { total: number; premium: number; freemium: number; verified: number; new_7d: number; new_30d: number }
|
||||
services: { counts: Record<string, number>; total: number; avg_per_owner: number; registry: ServiceRegistryEntry[] }
|
||||
engagement: { reservations: number; contributions: number; declarations: number; unique_guests: number; converted_guests: number; conversion_rate: number }
|
||||
stripe: { available: boolean; mrr_estimate: number }
|
||||
}
|
||||
|
||||
export async function fetchStats(): Promise<AdminStats> {
|
||||
return (await api.get('/stats')).data
|
||||
}
|
||||
|
||||
export interface AdminUserRow {
|
||||
id: string
|
||||
email: string
|
||||
is_premium: boolean
|
||||
email_verified: boolean
|
||||
created_at: string
|
||||
kdo_lists: number
|
||||
kontrib_lists: number
|
||||
kount_groups: number
|
||||
kal_polls: number
|
||||
kwiz_polls: number
|
||||
events: number
|
||||
stripe_customer_id: string | null
|
||||
}
|
||||
|
||||
export interface AdminUsersResponse {
|
||||
users: AdminUserRow[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
export async function fetchUsers(params: {
|
||||
q?: string
|
||||
filter?: 'all' | 'premium' | 'freemium'
|
||||
page?: number
|
||||
per_page?: number
|
||||
}): Promise<AdminUsersResponse> {
|
||||
return (await api.get('/users', { params })).data
|
||||
}
|
||||
|
||||
export interface AdminUserDetail {
|
||||
id: string
|
||||
email: string
|
||||
is_premium: boolean
|
||||
premium_until: string | null
|
||||
stripe_customer_id: string | null
|
||||
email_verified: boolean
|
||||
created_at: string
|
||||
kdo_lists_count: number
|
||||
kontrib_lists_count: number
|
||||
kount_groups_count: number
|
||||
kwiz_polls_count: number
|
||||
kal_polls_count: number
|
||||
events_count: number
|
||||
reservations_made: number
|
||||
contributions_made: number
|
||||
declarations_made: number
|
||||
co_owner_on: number
|
||||
services: {
|
||||
kdo_lists: { id: string; title: string; created_at: string }[]
|
||||
kontrib_lists: { id: string; title: string; created_at: string }[]
|
||||
kount_groups: { id: string; title: string; created_at: string }[]
|
||||
kwiz_polls: { id: string; title: string; created_at: string }[]
|
||||
kal_polls: { id: string; title: string; created_at: string }[]
|
||||
events: { id: string; title: string; created_at: string }[]
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchUser(id: string): Promise<AdminUserDetail> {
|
||||
return (await api.get(`/users/${id}`)).data
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, body: { is_premium?: boolean; premium_until?: string | null }): Promise<void> {
|
||||
await api.patch(`/users/${id}`, body)
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string): Promise<void> {
|
||||
await api.delete(`/users/${id}`)
|
||||
}
|
||||
|
||||
export interface DayCount { day: string; count: number; cumulative: number }
|
||||
|
||||
export async function createUser(body: {
|
||||
email: string
|
||||
password?: string
|
||||
email_verified?: boolean
|
||||
is_premium?: boolean
|
||||
}): Promise<{ id: string; email: string; created_at: string }> {
|
||||
return (await api.post('/users', body)).data
|
||||
}
|
||||
|
||||
export async function fetchUsersOverTime(days: number): Promise<DayCount[]> {
|
||||
return (await api.get('/stats/users-over-time', { params: { days } })).data
|
||||
}
|
||||
94
frontend/src/services/admin/pages/AdminApp.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { usePageMeta } from '@shared/hooks/usePageMeta'
|
||||
import { adminToken, verifyAdminToken } from '../api/admin'
|
||||
import AdminLogin from './AdminLogin'
|
||||
|
||||
const AdminDashboard = lazy(() => import('./AdminDashboard'))
|
||||
const AdminUsers = lazy(() => import('./AdminUsers'))
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="w-5 h-5 border-2 border-primary-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminLayout({ onLogout }: { onLogout: () => void }) {
|
||||
const navItem = 'flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-gray-500 hover:text-gray-900 hover:bg-gray-100'
|
||||
const activeNavItem = 'bg-primary-50 text-primary-700 border border-primary-200'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-52 shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
||||
<div className="px-4 py-5 border-b border-gray-200">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest">kankwa</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Administration</p>
|
||||
</div>
|
||||
<nav className="flex-1 p-3 space-y-0.5">
|
||||
<NavLink
|
||||
to="/admin/dashboard"
|
||||
className={({ isActive }) => `${navItem} ${isActive ? activeNavItem : ''}`}
|
||||
>
|
||||
<span>📊</span> Dashboard
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/users"
|
||||
className={({ isActive }) => `${navItem} ${isActive ? activeNavItem : ''}`}
|
||||
>
|
||||
<span>👥</span> Utilisateurs
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="p-3 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-full text-left px-3 py-2 rounded-lg text-xs text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<main className="flex-1 p-6 lg:p-8 overflow-y-auto">
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
<Route path="dashboard" element={<AdminDashboard />} />
|
||||
<Route path="users" element={<AdminUsers />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminApp() {
|
||||
usePageMeta({ title: 'Admin', noindex: true })
|
||||
const [state, setState] = useState<'loading' | 'login' | 'authenticated'>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
if (adminToken.get()) {
|
||||
verifyAdminToken().then(ok => setState(ok ? 'authenticated' : 'login'))
|
||||
} else {
|
||||
setState('login')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
adminToken.clear()
|
||||
setState('login')
|
||||
}
|
||||
|
||||
if (state === 'loading') return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-primary-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (state === 'login') return <AdminLogin onLogin={() => setState('authenticated')} />
|
||||
|
||||
return <AdminLayout onLogout={handleLogout} />
|
||||
}
|
||||
166
frontend/src/services/admin/pages/AdminDashboard.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { fetchStats, type AdminStats } from '../api/admin'
|
||||
import UserGrowthChart from './UserGrowthChart'
|
||||
import UserCumulativeChart from './UserCumulativeChart'
|
||||
|
||||
function StatCard({ label, value, sub, accent }: {
|
||||
label: string
|
||||
value: string | number
|
||||
sub?: string
|
||||
accent?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide mb-1">{label}</p>
|
||||
<p className={`text-2xl font-bold ${accent ?? 'text-gray-900'}`}>{value}</p>
|
||||
{sub && <p className="text-xs text-gray-400 mt-1">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return <h2 className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">{children}</h2>
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats().then(setStats).catch(() => setError('Erreur de chargement')).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-6 h-6 border-2 border-primary-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
if (error || !stats) return <p className="text-danger-600">{error}</p>
|
||||
|
||||
const { users, services, engagement } = stats
|
||||
const premiumPct = users.total > 0 ? Math.round(users.premium / users.total * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-1">Dashboard</h1>
|
||||
<p className="text-sm text-gray-500">Vue globale de la plateforme</p>
|
||||
</div>
|
||||
|
||||
{/* Users */}
|
||||
<div>
|
||||
<SectionTitle>Utilisateurs</SectionTitle>
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard label="Total" value={users.total} accent="text-primary-600" />
|
||||
<StatCard label="Kankwa+" value={users.premium} sub={`${premiumPct}% de la base`} accent="text-amber-600" />
|
||||
<StatCard label="Nouveaux 7j" value={`+${users.new_7d}`} accent="text-success-600" />
|
||||
<StatCard label="Nouveaux 30j" value={`+${users.new_30d}`} />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-500 w-16 shrink-0">Gratuit</span>
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-1.5">
|
||||
<div className="bg-primary-600 h-1.5 rounded-full" style={{ width: `${100 - premiumPct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-primary-600 w-6 text-right">{users.freemium}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 w-16 shrink-0">Kankwa+</span>
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-1.5">
|
||||
<div className="bg-amber-500 h-1.5 rounded-full" style={{ width: `${premiumPct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-amber-600 w-6 text-right">{users.premium}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-72 shrink-0 flex flex-col gap-3">
|
||||
<UserGrowthChart />
|
||||
<UserCumulativeChart />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div>
|
||||
<SectionTitle>Services</SectionTitle>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Total services" value={services.total} accent="text-primary-600" />
|
||||
<StatCard label="Moy. / owner" value={services.avg_per_owner} sub="services créés par propriétaire" accent="text-info-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Engagement */}
|
||||
<div>
|
||||
<SectionTitle>Engagement invités</SectionTitle>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Réservations" value={engagement.reservations} />
|
||||
<StatCard label="Contributions" value={engagement.contributions} />
|
||||
<StatCard label="Déclarations" value={engagement.declarations} />
|
||||
<StatCard label="Invités uniques" value={engagement.unique_guests} accent="text-primary-600" />
|
||||
</div>
|
||||
<div className="mt-3 bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide mb-0.5">Taux de conversion invités → membres</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="text-success-600 font-bold text-xl">{Math.round(engagement.conversion_rate * 100)}%</span>
|
||||
{' '}— {engagement.converted_guests} sur {engagement.unique_guests} invités tracés se sont inscrits
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-16 h-16 relative flex items-center justify-center">
|
||||
<svg viewBox="0 0 36 36" className="w-16 h-16 -rotate-90">
|
||||
<circle cx="18" cy="18" r="15.9" fill="none" stroke="#e5e7eb" strokeWidth="3" />
|
||||
<circle
|
||||
cx="18" cy="18" r="15.9" fill="none"
|
||||
stroke="#A0D4C8" strokeWidth="3"
|
||||
strokeDasharray={`${engagement.conversion_rate * 100} 100`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-xs font-bold text-success-600">
|
||||
{Math.round(engagement.conversion_rate * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenus */}
|
||||
<div>
|
||||
<SectionTitle>Revenus</SectionTitle>
|
||||
{stats.stripe.available ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
label="Abonnés Kankwa+"
|
||||
value={users.premium}
|
||||
accent="text-amber-600"
|
||||
sub={`${premiumPct}% de la base`}
|
||||
/>
|
||||
<StatCard
|
||||
label="MRR estimé"
|
||||
value={`${stats.stripe.mrr_estimate.toFixed(2)} €`}
|
||||
accent="text-success-600"
|
||||
sub="hypothèse : tout mensuel × 1,99 €"
|
||||
/>
|
||||
<StatCard
|
||||
label="ARR estimé"
|
||||
value={`${(stats.stripe.mrr_estimate * 12).toFixed(2)} €`}
|
||||
sub="MRR × 12"
|
||||
/>
|
||||
<StatCard
|
||||
label="Gratuit → Kankwa+"
|
||||
value={`${premiumPct} %`}
|
||||
sub={`${users.freemium} comptes gratuits restants`}
|
||||
accent="text-primary-600"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-dashed border-gray-300 rounded-xl p-6 text-center">
|
||||
<p className="text-gray-400 text-sm">Stripe non configuré</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
frontend/src/services/admin/pages/AdminLogin.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useState } from 'react'
|
||||
import { adminToken, verifyAdminToken } from '../api/admin'
|
||||
|
||||
export default function AdminLogin({ onLogin }: { onLogin: () => void }) {
|
||||
const [value, setValue] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
adminToken.set(value.trim())
|
||||
const ok = await verifyAdminToken()
|
||||
if (ok) {
|
||||
onLogin()
|
||||
} else {
|
||||
adminToken.clear()
|
||||
setError('Token invalide')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-1">kankwa</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Administration</h1>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 p-6 flex flex-col gap-4 shadow-sm">
|
||||
{error && (
|
||||
<p className="text-sm text-danger-600 bg-danger-50 border border-danger-100 rounded-lg px-3 py-2">{error}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Token d'accès</label>
|
||||
<input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
className="input"
|
||||
placeholder="••••••••••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !value.trim()}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Vérification…' : 'Accéder'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-xs text-gray-400 mt-4">Session sécurisée — fermer l'onglet déconnecte</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
497
frontend/src/services/admin/pages/AdminUsers.tsx
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
fetchUsers, fetchUser, updateUser, createUser, deleteUser,
|
||||
type AdminUserRow, type AdminUserDetail,
|
||||
} from '../api/admin'
|
||||
|
||||
function Badge({ premium }: { premium: boolean }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
premium ? 'bg-amber-50 text-amber-700 border border-amber-200' : 'bg-gray-100 text-gray-500 border border-gray-200'
|
||||
}`}>
|
||||
{premium ? 'Kankwa+' : 'Plan Gratuit'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function fmt(dt: string) {
|
||||
return new Date(dt).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
// ─── Create user modal ───────────────────────────────────────────────────────
|
||||
|
||||
function CreateUserModal({ onClose, onCreated }: {
|
||||
onClose: () => void
|
||||
onCreated: (id: string) => void
|
||||
}) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [emailVerified, setEmailVerified] = useState(true)
|
||||
const [isPremium, setIsPremium] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const user = await createUser({
|
||||
email,
|
||||
password: password || undefined,
|
||||
email_verified: emailVerified,
|
||||
is_premium: isPremium,
|
||||
})
|
||||
onCreated(user.id)
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail ?? 'Erreur lors de la création')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-md p-6 flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-900">Créer un utilisateur</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-700 text-xl leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 mb-1 block">Email <span className="text-danger-500">*</span></label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="input w-full"
|
||||
placeholder="prenom@exemple.fr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 mb-1 block">Mot de passe <span className="text-gray-400">(optionnel)</span></label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="input w-full"
|
||||
placeholder="Laisser vide → connexion magic link uniquement"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={emailVerified}
|
||||
onChange={e => setEmailVerified(e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Email vérifié</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPremium}
|
||||
onChange={e => setIsPremium(e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Compte Kankwa+</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger-600">{error}</p>}
|
||||
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<button type="button" onClick={onClose} className="btn-secondary btn-sm">Annuler</button>
|
||||
<button type="submit" disabled={saving || !email} className="btn-primary btn-sm disabled:opacity-50">
|
||||
{saving ? '…' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── User slide-over ─────────────────────────────────────────────────────────
|
||||
|
||||
function UserDetail({ userId, onClose, onUpdated, onDeleted }: {
|
||||
userId: string
|
||||
onClose: () => void
|
||||
onUpdated: (row: Partial<AdminUserRow>) => void
|
||||
onDeleted: (id: string) => void
|
||||
}) {
|
||||
const [user, setUser] = useState<AdminUserDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [premiumUntil, setPremiumUntil] = useState('')
|
||||
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser(userId)
|
||||
.then(u => {
|
||||
setUser(u)
|
||||
setPremiumUntil(u.premium_until ? u.premium_until.slice(0, 10) : '')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [userId])
|
||||
|
||||
const togglePremium = async () => {
|
||||
if (!user) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateUser(user.id, {
|
||||
is_premium: !user.is_premium,
|
||||
premium_until: !user.is_premium && premiumUntil ? premiumUntil : null,
|
||||
})
|
||||
setUser(u => u ? { ...u, is_premium: !u.is_premium } : u)
|
||||
onUpdated({ id: userId, is_premium: !user.is_premium })
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const savePremiumUntil = async () => {
|
||||
if (!user) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateUser(user.id, { premium_until: premiumUntil || null })
|
||||
setUser(u => u ? { ...u, premium_until: premiumUntil || null } : u)
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!user || deleteConfirm !== user.email) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteUser(user.id)
|
||||
onDeleted(user.id)
|
||||
onClose()
|
||||
} finally { setDeleting(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative w-full max-w-lg bg-white border-l border-gray-200 h-full overflow-y-auto flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<h2 className="font-semibold text-gray-900 text-sm">Détail utilisateur</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-700 text-xl leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<div className="w-5 h-5 border-2 border-primary-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : !user ? (
|
||||
<p className="text-danger-600 p-6">Erreur de chargement</p>
|
||||
) : (
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
|
||||
{/* Identity */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide mb-3">Identité</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Email</span>
|
||||
<span className="text-gray-900 font-medium">{user.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Inscrit le</span>
|
||||
<span className="text-gray-700">{fmt(user.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Email vérifié</span>
|
||||
<span className={user.email_verified ? 'text-success-600' : 'text-danger-600'}>
|
||||
{user.email_verified ? 'Oui' : 'Non'}
|
||||
</span>
|
||||
</div>
|
||||
{user.stripe_customer_id && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Stripe ID</span>
|
||||
<span className="text-gray-400 font-mono text-xs">{user.stripe_customer_id}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscription */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide mb-3">Abonnement</p>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Badge premium={user.is_premium} />
|
||||
{user.premium_until && (
|
||||
<p className="text-xs text-gray-400 mt-1">Jusqu'au {fmt(user.premium_until)}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={togglePremium}
|
||||
disabled={saving}
|
||||
className={`text-xs px-3 py-1.5 rounded-lg font-medium transition-colors disabled:opacity-50 ${
|
||||
user.is_premium
|
||||
? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
: 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
}`}
|
||||
>
|
||||
{user.is_premium ? '↓ Passer en gratuit' : '↑ Activer Kankwa+'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-500 mb-1 block">Kankwa+ jusqu'au</label>
|
||||
<input
|
||||
type="date"
|
||||
value={premiumUntil}
|
||||
onChange={e => setPremiumUntil(e.target.value)}
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={savePremiumUntil}
|
||||
disabled={saving}
|
||||
className="btn-primary btn-sm disabled:opacity-50"
|
||||
>
|
||||
{saving ? '…' : 'Sauvegarder'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide mb-3">Activité</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ label: 'Wishlists', v: user.kdo_lists_count },
|
||||
{ label: 'Listes coll.', v: user.kontrib_lists_count },
|
||||
{ label: 'Dépenses', v: user.kount_groups_count },
|
||||
{ label: 'Sondages', v: user.kwiz_polls_count },
|
||||
{ label: 'Dispos', v: user.kal_polls_count },
|
||||
{ label: 'Projets Hub', v: user.events_count },
|
||||
{ label: 'Réservations', v: user.reservations_made },
|
||||
{ label: 'Contributions', v: user.contributions_made },
|
||||
{ label: 'Déclarations', v: user.declarations_made },
|
||||
].map(({ label, v }) => (
|
||||
<div key={label} className="bg-gray-100 rounded-lg p-2.5 text-center">
|
||||
<p className="text-lg font-bold text-gray-900">{v}</p>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{user.co_owner_on > 0 && (
|
||||
<p className="text-xs text-gray-400 mt-2">Co-propriétaire sur {user.co_owner_on} ressource{user.co_owner_on > 1 ? 's' : ''}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="border border-danger-200 rounded-xl p-4 flex flex-col gap-3">
|
||||
<p className="text-xs text-danger-600 font-semibold uppercase tracking-wide">Zone de danger</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
La suppression est <strong>irréversible</strong> — toutes les ressources de cet utilisateur seront effacées.
|
||||
Tapez l'adresse email pour confirmer.
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={user.email}
|
||||
value={deleteConfirm}
|
||||
onChange={e => setDeleteConfirm(e.target.value)}
|
||||
className="input w-full text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteConfirm !== user.email || deleting}
|
||||
className="w-full py-2 rounded-lg text-sm font-medium bg-danger-600 text-white hover:bg-danger-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{deleting ? 'Suppression…' : 'Supprimer le compte'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Users list ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminUsers() {
|
||||
const [users, setUsers] = useState<AdminUserRow[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [q, setQ] = useState('')
|
||||
const [filter, setFilter] = useState<'all' | 'premium' | 'freemium'>('all')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const PER_PAGE = 25
|
||||
const searchTimeout = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
const load = (p = page, query = q, f = filter) => {
|
||||
setLoading(true)
|
||||
fetchUsers({ q: query || undefined, filter: f, page: p, per_page: PER_PAGE })
|
||||
.then(r => { setUsers(r.users); setTotal(r.total) })
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const handleSearch = (val: string) => {
|
||||
setQ(val)
|
||||
setPage(1)
|
||||
clearTimeout(searchTimeout.current)
|
||||
searchTimeout.current = setTimeout(() => load(1, val, filter), 350)
|
||||
}
|
||||
|
||||
const handleFilter = (f: 'all' | 'premium' | 'freemium') => {
|
||||
setFilter(f)
|
||||
setPage(1)
|
||||
load(1, q, f)
|
||||
}
|
||||
|
||||
const handlePage = (p: number) => { setPage(p); load(p) }
|
||||
|
||||
const totalPages = Math.ceil(total / PER_PAGE)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-1">Utilisateurs</h1>
|
||||
<p className="text-sm text-gray-500">{total} compte{total > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)} className="btn-primary btn-sm">
|
||||
+ Créer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Rechercher par email…"
|
||||
value={q}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
className="flex-1 input"
|
||||
/>
|
||||
<div className="flex gap-1 bg-white border border-gray-200 rounded-lg p-1">
|
||||
{(['all', 'freemium', 'premium'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => handleFilter(f)}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
filter === f ? 'bg-primary-600 text-white' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Tous' : f === 'premium' ? 'Kankwa+' : 'Plan Gratuit'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="w-5 h-5 border-2 border-primary-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<p className="text-center text-gray-400 py-12 text-sm">Aucun utilisateur trouvé</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs text-gray-400 uppercase tracking-wide">
|
||||
<th className="px-4 py-3 text-left">Email</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Statut</th>
|
||||
<th className="px-4 py-3 text-right hidden md:table-cell">Services</th>
|
||||
<th className="px-4 py-3 text-right">Inscription</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{users.map(u => (
|
||||
<tr
|
||||
key={u.id}
|
||||
onClick={() => setSelectedId(u.id)}
|
||||
className="hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-gray-900 font-medium">{u.email}</span>
|
||||
{!u.email_verified && (
|
||||
<span className="ml-2 text-xs text-gray-400">non vérifié</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<Badge premium={u.is_premium} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right hidden md:table-cell">
|
||||
<span className="text-gray-500">
|
||||
{u.kdo_lists + u.kontrib_lists + u.kount_groups + u.kal_polls + u.kwiz_polls + u.events}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-400 text-xs">
|
||||
{fmt(u.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<button
|
||||
onClick={() => handlePage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="btn-secondary btn-sm disabled:opacity-30"
|
||||
>
|
||||
← Précédent
|
||||
</button>
|
||||
<span className="text-gray-400">Page {page} / {totalPages}</span>
|
||||
<button
|
||||
onClick={() => handlePage(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="btn-secondary btn-sm disabled:opacity-30"
|
||||
>
|
||||
Suivant →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<CreateUserModal
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={id => {
|
||||
setShowCreate(false)
|
||||
load(1, '', 'all')
|
||||
setSelectedId(id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedId && (
|
||||
<UserDetail
|
||||
userId={selectedId}
|
||||
onClose={() => setSelectedId(null)}
|
||||
onUpdated={updated => setUsers(prev =>
|
||||
prev.map(u => u.id === updated.id ? { ...u, ...updated } : u)
|
||||
)}
|
||||
onDeleted={id => {
|
||||
setUsers(prev => prev.filter(u => u.id !== id))
|
||||
setTotal(t => t - 1)
|
||||
setSelectedId(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
frontend/src/services/admin/pages/UserCumulativeChart.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { fetchUsersOverTime, type DayCount } from '../api/admin'
|
||||
|
||||
const PERIODS = [
|
||||
{ label: '7j', days: 7 },
|
||||
{ label: '30j', days: 30 },
|
||||
{ label: '90j', days: 90 },
|
||||
{ label: '1an', days: 365 },
|
||||
] as const
|
||||
|
||||
const W = 500
|
||||
const H = 120
|
||||
const PAD = { top: 10, right: 8, bottom: 24, left: 34 }
|
||||
const INNER_W = W - PAD.left - PAD.right
|
||||
const INNER_H = H - PAD.top - PAD.bottom
|
||||
|
||||
function fmtDay(iso: string, days: number) {
|
||||
const d = new Date(iso)
|
||||
if (days <= 90) return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })
|
||||
return d.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' })
|
||||
}
|
||||
|
||||
export default function UserCumulativeChart() {
|
||||
const [days, setDays] = useState(30)
|
||||
const [data, setData] = useState<DayCount[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; d: DayCount } | null>(null)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchUsersOverTime(days).then(setData).finally(() => setLoading(false))
|
||||
}, [days])
|
||||
|
||||
const values = data.map(d => d.cumulative)
|
||||
const minVal = values[0] ?? 0
|
||||
const maxVal = Math.max(...values, minVal + 1)
|
||||
|
||||
const xOf = (i: number) => PAD.left + (i / (data.length - 1 || 1)) * INNER_W
|
||||
const yOf = (v: number) => PAD.top + INNER_H - ((v - minVal) / (maxVal - minVal || 1)) * INNER_H
|
||||
|
||||
const tickStep = Math.max(1, Math.floor(data.length / 5))
|
||||
const xTicks = data.filter((_, i) => i % tickStep === 0 || i === data.length - 1)
|
||||
|
||||
const yMid = Math.round((minVal + maxVal) / 2)
|
||||
const ySteps = [minVal, yMid, maxVal]
|
||||
|
||||
const linePath = data.length < 2 ? '' : data
|
||||
.map((d, i) => `${i === 0 ? 'M' : 'L'}${xOf(i).toFixed(1)},${yOf(d.cumulative).toFixed(1)}`)
|
||||
.join(' ')
|
||||
|
||||
const areaPath = data.length < 2 ? '' :
|
||||
`${linePath} L${xOf(data.length - 1).toFixed(1)},${(PAD.top + INNER_H).toFixed(1)} L${xOf(0).toFixed(1)},${(PAD.top + INNER_H).toFixed(1)} Z`
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!svgRef.current || data.length === 0) return
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const svgX = ((e.clientX - rect.left) / rect.width) * W
|
||||
const idx = Math.round(((svgX - PAD.left) / INNER_W) * (data.length - 1))
|
||||
const clamped = Math.max(0, Math.min(idx, data.length - 1))
|
||||
const d = data[clamped]
|
||||
setTooltip({ x: xOf(clamped), y: yOf(d.cumulative), d })
|
||||
}, [data])
|
||||
|
||||
const last = data[data.length - 1]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-xs text-gray-500">Total cumulé</p>
|
||||
{!loading && last && (
|
||||
<span className="text-sm font-bold text-success-600">{last.cumulative}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-0.5 bg-gray-100 border border-gray-200 rounded-lg p-0.5">
|
||||
{PERIODS.map(p => (
|
||||
<button
|
||||
key={p.days}
|
||||
onClick={() => setDays(p.days)}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||
days === p.days ? 'bg-success-600 text-white' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60 rounded">
|
||||
<div className="w-4 h-4 border-2 border-success-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full select-none"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="cumulGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#A0D4C8" stopOpacity="0.2" />
|
||||
<stop offset="100%" stopColor="#A0D4C8" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{ySteps.map(v => (
|
||||
<g key={v}>
|
||||
<line x1={PAD.left} y1={yOf(v)} x2={PAD.left + INNER_W} y2={yOf(v)} stroke="#e5e7eb" strokeWidth="1" />
|
||||
<text x={PAD.left - 5} y={yOf(v) + 3.5} textAnchor="end" fontSize="8" fill="#9ca3af">{v}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{areaPath && <path d={areaPath} fill="url(#cumulGrad)" />}
|
||||
{linePath && <path d={linePath} fill="none" stroke="#A0D4C8" strokeWidth="1.8" strokeLinejoin="round" />}
|
||||
|
||||
{xTicks.map(d => (
|
||||
<text key={d.day} x={xOf(data.indexOf(d))} y={PAD.top + INNER_H + 16}
|
||||
textAnchor="middle" fontSize="8" fill="#9ca3af">
|
||||
{fmtDay(d.day, days)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{tooltip && (
|
||||
<g>
|
||||
<line x1={tooltip.x} y1={PAD.top} x2={tooltip.x} y2={PAD.top + INNER_H}
|
||||
stroke="#A0D4C8" strokeWidth="1" strokeDasharray="3,3" opacity="0.5" />
|
||||
<circle cx={tooltip.x} cy={tooltip.y} r="3" fill="#A0D4C8" />
|
||||
{(() => {
|
||||
const bx = tooltip.x + (tooltip.x > W - 100 ? -98 : 8)
|
||||
const by = Math.max(PAD.top, tooltip.y - 18)
|
||||
return (
|
||||
<g>
|
||||
<rect x={bx} y={by} width={88} height={32} rx="4" fill="white" stroke="#e5e7eb" strokeWidth="1" />
|
||||
<text x={bx + 44} y={by + 11} textAnchor="middle" fontSize="8" fill="#6b7280">
|
||||
{fmtDay(tooltip.d.day, days)}
|
||||
</text>
|
||||
<text x={bx + 44} y={by + 24} textAnchor="middle" fontSize="11" fontWeight="600" fill="#111827">
|
||||
{tooltip.d.cumulative} utilisateurs
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})()}
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
frontend/src/services/admin/pages/UserGrowthChart.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { fetchUsersOverTime, type DayCount } from '../api/admin'
|
||||
|
||||
const PERIODS = [
|
||||
{ label: '7j', days: 7 },
|
||||
{ label: '30j', days: 30 },
|
||||
{ label: '90j', days: 90 },
|
||||
{ label: '1an', days: 365 },
|
||||
] as const
|
||||
|
||||
const W = 500
|
||||
const H = 140
|
||||
const PAD = { top: 12, right: 8, bottom: 28, left: 30 }
|
||||
const INNER_W = W - PAD.left - PAD.right
|
||||
const INNER_H = H - PAD.top - PAD.bottom
|
||||
|
||||
function fmtDay(iso: string, days: number) {
|
||||
const d = new Date(iso)
|
||||
if (days <= 30) return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })
|
||||
if (days <= 90) return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })
|
||||
return d.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' })
|
||||
}
|
||||
|
||||
export default function UserGrowthChart() {
|
||||
const [days, setDays] = useState(30)
|
||||
const [data, setData] = useState<DayCount[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; d: DayCount } | null>(null)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchUsersOverTime(days)
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false))
|
||||
}, [days])
|
||||
|
||||
const maxCount = Math.max(...data.map(d => d.count), 1)
|
||||
const total = data.reduce((s, d) => s + d.count, 0)
|
||||
|
||||
const xOf = (i: number) => PAD.left + (i / (data.length - 1 || 1)) * INNER_W
|
||||
const yOf = (v: number) => PAD.top + INNER_H - (v / maxCount) * INNER_H
|
||||
|
||||
const tickStep = Math.max(1, Math.floor(data.length / 6))
|
||||
const xTicks = data.filter((_, i) => i % tickStep === 0 || i === data.length - 1)
|
||||
|
||||
const ySteps = [0, 0.25, 0.5, 0.75, 1].map(f => Math.round(f * maxCount))
|
||||
|
||||
const linePath = data.length < 2 ? '' : data
|
||||
.map((d, i) => `${i === 0 ? 'M' : 'L'}${xOf(i).toFixed(1)},${yOf(d.count).toFixed(1)}`)
|
||||
.join(' ')
|
||||
|
||||
const areaPath = data.length < 2 ? '' :
|
||||
`${linePath} L${xOf(data.length - 1).toFixed(1)},${(PAD.top + INNER_H).toFixed(1)} L${xOf(0).toFixed(1)},${(PAD.top + INNER_H).toFixed(1)} Z`
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!svgRef.current || data.length === 0) return
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const svgX = ((e.clientX - rect.left) / rect.width) * W
|
||||
const relX = svgX - PAD.left
|
||||
const idx = Math.round((relX / INNER_W) * (data.length - 1))
|
||||
const clamped = Math.max(0, Math.min(idx, data.length - 1))
|
||||
const d = data[clamped]
|
||||
setTooltip({ x: xOf(clamped), y: yOf(d.count), d })
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-xs text-gray-500">Inscriptions</p>
|
||||
{!loading && <span className="text-sm font-bold text-primary-600">+{total}</span>}
|
||||
</div>
|
||||
<div className="flex gap-0.5 bg-gray-100 border border-gray-200 rounded-lg p-0.5">
|
||||
{PERIODS.map(p => (
|
||||
<button
|
||||
key={p.days}
|
||||
onClick={() => setDays(p.days)}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||
days === p.days ? 'bg-primary-600 text-white' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60 rounded">
|
||||
<div className="w-4 h-4 border-2 border-primary-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full select-none"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#C77548" stopOpacity="0.2" />
|
||||
<stop offset="100%" stopColor="#C77548" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{ySteps.map(v => {
|
||||
const y = yOf(v)
|
||||
return (
|
||||
<g key={v}>
|
||||
<line x1={PAD.left} y1={y} x2={PAD.left + INNER_W} y2={y} stroke="#e5e7eb" strokeWidth="1" />
|
||||
<text x={PAD.left - 6} y={y + 4} textAnchor="end" fontSize="9" fill="#9ca3af">{v}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{areaPath && <path d={areaPath} fill="url(#areaGrad)" />}
|
||||
{linePath && <path d={linePath} fill="none" stroke="#C77548" strokeWidth="1.8" strokeLinejoin="round" />}
|
||||
|
||||
{xTicks.map(d => {
|
||||
const i = data.indexOf(d)
|
||||
return (
|
||||
<text key={d.day} x={xOf(i)} y={PAD.top + INNER_H + 20} textAnchor="middle" fontSize="9" fill="#9ca3af">
|
||||
{fmtDay(d.day, days)}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
|
||||
{tooltip && (
|
||||
<g>
|
||||
<line x1={tooltip.x} y1={PAD.top} x2={tooltip.x} y2={PAD.top + INNER_H}
|
||||
stroke="#C77548" strokeWidth="1" strokeDasharray="3,3" opacity="0.5" />
|
||||
<circle cx={tooltip.x} cy={tooltip.y} r="3.5" fill="#C77548" />
|
||||
{(() => {
|
||||
const boxW = 90
|
||||
const boxH = 36
|
||||
const bx = tooltip.x + (tooltip.x > W - boxW - 20 ? -boxW - 8 : 10)
|
||||
const by = Math.max(PAD.top, tooltip.y - boxH / 2)
|
||||
return (
|
||||
<g>
|
||||
<rect x={bx} y={by} width={boxW} height={boxH} rx="5" fill="white" stroke="#e5e7eb" strokeWidth="1" />
|
||||
<text x={bx + boxW / 2} y={by + 13} textAnchor="middle" fontSize="9" fill="#6b7280">
|
||||
{fmtDay(tooltip.d.day, days)}
|
||||
</text>
|
||||
<text x={bx + boxW / 2} y={by + 27} textAnchor="middle" fontSize="12" fontWeight="600" fill="#111827">
|
||||
+{tooltip.d.count} utilisateur{tooltip.d.count !== 1 ? 's' : ''}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})()}
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
frontend/src/services/hub/api/hub.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import api from '@shared/api/client'
|
||||
|
||||
export interface EventSummary {
|
||||
id: string
|
||||
title: string
|
||||
event_type: string | null
|
||||
event_date: string | null
|
||||
location: string | null
|
||||
share_token: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AttachedService {
|
||||
id: string
|
||||
service_type: string
|
||||
resource_id: string
|
||||
is_visible: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface EventNotification {
|
||||
id: string
|
||||
type: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface EventDetail {
|
||||
id: string
|
||||
user_id: string
|
||||
title: string
|
||||
event_type: string | null
|
||||
event_date: string | null
|
||||
location: string | null
|
||||
description: string | null
|
||||
share_token: string
|
||||
created_at: string
|
||||
services: AttachedService[]
|
||||
notifications: EventNotification[]
|
||||
}
|
||||
|
||||
export interface AttachedServicePublic {
|
||||
id: string
|
||||
service_type: string
|
||||
resource_id: string
|
||||
public_token: string | null
|
||||
}
|
||||
|
||||
export interface EventPublic {
|
||||
id: string
|
||||
title: string
|
||||
event_type: string | null
|
||||
event_date: string | null
|
||||
location: string | null
|
||||
description: string | null
|
||||
services: AttachedServicePublic[]
|
||||
}
|
||||
|
||||
export const getEvents = () =>
|
||||
api.get<EventSummary[]>('/hub').then(r => r.data)
|
||||
|
||||
export const getEvent = (id: string) =>
|
||||
api.get<EventDetail>(`/hub/${id}`).then(r => r.data)
|
||||
|
||||
export const createEvent = (data: {
|
||||
title: string
|
||||
event_type?: string | null
|
||||
event_date?: string | null
|
||||
location?: string | null
|
||||
description?: string | null
|
||||
}) => api.post<EventDetail>('/hub', data).then(r => r.data)
|
||||
|
||||
export const updateEvent = (id: string, data: Partial<{
|
||||
title: string
|
||||
event_type: string | null
|
||||
event_date: string | null
|
||||
location: string | null
|
||||
description: string | null
|
||||
}>) => api.put<EventDetail>(`/hub/${id}`, data).then(r => r.data)
|
||||
|
||||
export const deleteEvent = (id: string) =>
|
||||
api.delete(`/hub/${id}`)
|
||||
|
||||
export const getPublicEvent = (shareToken: string) =>
|
||||
api.get<EventPublic>(`/hub/shared/${shareToken}`).then(r => r.data)
|
||||
|
||||
export const attachService = (eventId: string, data: { service_type: string; resource_id: string }) =>
|
||||
api.post<AttachedService>(`/hub/${eventId}/services`, data).then(r => r.data)
|
||||
|
||||
export const updateServiceVisibility = (eventId: string, serviceId: string, is_visible: boolean) =>
|
||||
api.patch<AttachedService>(`/hub/${eventId}/services/${serviceId}/visibility`, { is_visible }).then(r => r.data)
|
||||
|
||||
export const detachService = (eventId: string, serviceId: string) =>
|
||||
api.delete(`/hub/${eventId}/services/${serviceId}`)
|
||||
|
||||
export interface MyContributionItem {
|
||||
type: 'reservation' | 'contribution' | 'declaration' | 'kount_expense'
|
||||
cancel_token: string
|
||||
participant_name: string
|
||||
created_at: string
|
||||
service_title: string
|
||||
item_name: string | null
|
||||
amount: string | null
|
||||
public_token: string | null
|
||||
}
|
||||
|
||||
export const getMyContributions = () =>
|
||||
api.get<MyContributionItem[]>('/hub/my-contributions').then(r => r.data)
|
||||
72
frontend/src/services/hub/components/CreateHubModal.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { createEvent } from '@services/hub/api/hub'
|
||||
import { Alert, DateInput, Modal } from '@shared/components'
|
||||
import { EVENT_TYPE_OPTIONS } from '@shared/types'
|
||||
import { extractApiError } from '@shared/utils/error'
|
||||
import type { ApiError } from '@shared/types'
|
||||
|
||||
export default function CreateHubModal({ onClose }: { onClose: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
const [title, setTitle] = useState('')
|
||||
const [eventType, setEventType] = useState('')
|
||||
const [eventDate, setEventDate] = useState('')
|
||||
const [location, setLocation] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [limitReached, setLimitReached] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setLimitReached('')
|
||||
try {
|
||||
const ev = await createEvent({
|
||||
title,
|
||||
event_type: eventType || null,
|
||||
event_date: eventDate || null,
|
||||
location: location || null,
|
||||
description: description || null,
|
||||
})
|
||||
onClose()
|
||||
navigate(`/hub/${ev.id}`)
|
||||
} catch (err) {
|
||||
if ((err as ApiError)?.response?.status === 403) {
|
||||
setLimitReached(extractApiError(err, 'Limite de services atteinte.'))
|
||||
} else {
|
||||
setError(extractApiError(err, 'Erreur lors de la création'))
|
||||
}
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} title="Nouveau projet">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<Alert type="error" message={error} />
|
||||
{limitReached && (
|
||||
<p className="text-sm text-warning-700 bg-warning-50 border border-warning-200 rounded-lg px-3 py-2.5">
|
||||
{limitReached}{' '}
|
||||
<Link to="/compte" onClick={onClose} className="underline font-medium hover:text-warning-800">
|
||||
Voir mon compte
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
<input className="input" placeholder="Nom du projet *" value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
|
||||
<select className="input" value={eventType} onChange={e => setEventType(e.target.value)}>
|
||||
{EVENT_TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<DateInput value={eventDate} onChange={setEventDate} />
|
||||
<input className="input" placeholder="Lieu (optionnel)" value={location} onChange={e => setLocation(e.target.value)} />
|
||||
<textarea className="input" rows={3} placeholder="Description (optionnel)" value={description} onChange={e => setDescription(e.target.value)} />
|
||||
<div className="modal-actions">
|
||||
<button type="submit" disabled={loading || !title.trim()} className="btn-primary flex-1">
|
||||
{loading ? 'Création…' : "Créer le projet"}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="btn-secondary">Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
79
frontend/src/services/hub/components/EditHubModal.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { useState } from 'react'
|
||||
import { deleteEvent, updateEvent } from '@services/hub/api/hub'
|
||||
import type { EditModalProps } from '@shared/components/ServiceDashboard'
|
||||
import { Alert, DateInput, Modal } from '@shared/components'
|
||||
import { useConfirm } from '@shared/hooks/useConfirm'
|
||||
import { EVENT_TYPE_OPTIONS } from '@shared/types'
|
||||
import { extractApiError } from '@shared/utils/error'
|
||||
|
||||
interface EditableEvent {
|
||||
id: string
|
||||
title: string
|
||||
event_type?: string | null
|
||||
event_date?: string | null
|
||||
location?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export default function EditHubModal({ item, onClose, onSaved, onDeleted }: EditModalProps<EditableEvent>) {
|
||||
const { confirm, element: confirmElement } = useConfirm()
|
||||
const [title, setTitle] = useState(item.title)
|
||||
const [eventType, setEventType] = useState(item.event_type ?? '')
|
||||
const [eventDate, setEventDate] = useState(item.event_date ?? '')
|
||||
const [location, setLocation] = useState(item.location ?? '')
|
||||
const [description, setDescription] = useState(item.description ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const updated = await updateEvent(item.id, {
|
||||
title: title.trim(),
|
||||
event_type: eventType || null,
|
||||
event_date: eventDate || null,
|
||||
location: location || null,
|
||||
description: description || null,
|
||||
})
|
||||
onSaved({ title: updated.title, event_type: updated.event_type, event_date: updated.event_date, location: updated.location, description: updated.description })
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(extractApiError(err, 'Erreur lors de la sauvegarde'))
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!await confirm(`Supprimer le projet "${item.title}" ? Cette action est irréversible.`, { confirmLabel: 'Supprimer' })) return
|
||||
await deleteEvent(item.id)
|
||||
onDeleted()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} title="Modifier le projet">
|
||||
{confirmElement}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<Alert type="error" message={error} />
|
||||
<input className="input" value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
|
||||
<select className="input" value={eventType} onChange={e => setEventType(e.target.value)}>
|
||||
{EVENT_TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<DateInput value={eventDate} onChange={setEventDate} />
|
||||
<input className="input" placeholder="Lieu (optionnel)" value={location} onChange={e => setLocation(e.target.value)} />
|
||||
<textarea className="input" rows={3} placeholder="Description (optionnel)" value={description} onChange={e => setDescription(e.target.value)} />
|
||||
<div className="modal-actions">
|
||||
<button type="submit" disabled={saving} className="btn-primary flex-1">
|
||||
{saving ? 'Enregistrement…' : 'Enregistrer'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="btn-secondary">Annuler</button>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<button type="button" onClick={handleDelete} className="btn-danger btn-sm w-full">
|
||||
Supprimer le projet
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
57
frontend/src/services/hub/config.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { lazy, Suspense } from 'react'
|
||||
import type { ServiceConfig } from '@shared/types/service'
|
||||
import { AuthGuard, PublicRedirectGuard } from '@shared/components'
|
||||
import ServiceRoute from '@shared/components/ServiceRoute'
|
||||
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const HubNew = lazy(() => import('./pages/HubNew'))
|
||||
const HubEdit = lazy(() => import('./pages/HubEdit'))
|
||||
const Vitrine = lazy(() => import('./pages/Vitrine'))
|
||||
|
||||
const wrap = (el: React.ReactNode) => (
|
||||
<AuthGuard><Suspense fallback={null}>{el}</Suspense></AuthGuard>
|
||||
)
|
||||
const wrapMain = (el: React.ReactNode) => (
|
||||
<ServiceRoute vitrine={<Suspense fallback={null}><Vitrine /></Suspense>}>
|
||||
{wrap(el)}
|
||||
</ServiceRoute>
|
||||
)
|
||||
const wrapOwner = (el: React.ReactNode, resourceType: string, idParam = 'id') => (
|
||||
<PublicRedirectGuard resourceType={resourceType} idParam={idParam}>
|
||||
<Suspense fallback={null}>{el}</Suspense>
|
||||
</PublicRedirectGuard>
|
||||
)
|
||||
|
||||
export const config: ServiceConfig = {
|
||||
id: 'hub',
|
||||
label: 'Hub',
|
||||
desc: 'La page qui rassemble tout : invités, services, décisions.',
|
||||
path: '/hub',
|
||||
active: true,
|
||||
hub: true,
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" className="w-5 h-5">
|
||||
<line x1="4" y1="6" x2="18" y2="4" />
|
||||
<line x1="4" y1="6" x2="3" y2="17" />
|
||||
<line x1="4" y1="6" x2="21" y2="15" />
|
||||
<line x1="18" y1="4" x2="21" y2="15" />
|
||||
<line x1="18" y1="4" x2="9" y2="21" />
|
||||
<line x1="21" y1="15" x2="9" y2="21" />
|
||||
<line x1="9" y1="21" x2="3" y2="17" />
|
||||
<line x1="3" y1="17" x2="18" y2="4" />
|
||||
<circle cx="4" cy="6" r="1.5" fill="currentColor" stroke="none" />
|
||||
<circle cx="18" cy="4" r="1.5" fill="currentColor" stroke="none" />
|
||||
<circle cx="21" cy="15" r="1.5" fill="currentColor" stroke="none" />
|
||||
<circle cx="9" cy="21" r="1.5" fill="currentColor" stroke="none" />
|
||||
<circle cx="3" cy="17" r="1.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
),
|
||||
routes: [
|
||||
{ path: '/hub', element: wrapMain(<Dashboard />) },
|
||||
{ path: '/hub/new', element: wrap(<HubNew />) },
|
||||
{ path: '/hub/:eventId', element: wrapOwner(<HubEdit />, 'hub', 'eventId') },
|
||||
],
|
||||
navItems: [
|
||||
{ label: 'Mon Hub', path: '/hub' },
|
||||
],
|
||||
}
|
||||
227
frontend/src/services/hub/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { deleteEvent, getEvents, getMyContributions, type EventSummary, type MyContributionItem } from '@services/hub/api/hub'
|
||||
import { EmptyState, Modal, ResourceCard, ServiceDashboard, ShareButton, Spinner } from '@shared/components'
|
||||
import { EVENT_TYPE_LABELS } from '@shared/types'
|
||||
import { formatDate } from '@shared/utils/formatDate'
|
||||
import CreateHubModal from '@services/hub/components/CreateHubModal'
|
||||
import EditHubModal from '@services/hub/components/EditHubModal'
|
||||
import { usePageTitle } from '@shared/hooks/usePageTitle'
|
||||
import { cancelReservation } from '@services/kdo/api/reservations'
|
||||
import { cancelContribution } from '@services/kdo/api/items'
|
||||
import { cancelDeclaration } from '@services/kontrib/api/kontrib'
|
||||
import { cancelExpense } from '@services/kount/api/kount'
|
||||
|
||||
const TYPE_CONFIG: Record<MyContributionItem['type'], {
|
||||
label: string
|
||||
badge: string
|
||||
dateLabel: string
|
||||
confirmDesc: string
|
||||
serviceUrl: (token: string) => string
|
||||
}> = {
|
||||
reservation: {
|
||||
label: 'Réservation',
|
||||
badge: 'bg-primary-50 text-primary-700',
|
||||
dateLabel: 'Réservé le',
|
||||
confirmDesc: 'Le cadeau sera à nouveau disponible pour les autres.',
|
||||
serviceUrl: (t) => `/share/${t}`,
|
||||
},
|
||||
contribution: {
|
||||
label: 'Participation',
|
||||
badge: 'bg-success-50 text-success-700',
|
||||
dateLabel: 'Participé le',
|
||||
confirmDesc: 'Ta contribution sera retirée de la collecte.',
|
||||
serviceUrl: (t) => `/share/${t}`,
|
||||
},
|
||||
declaration: {
|
||||
label: 'Contribution',
|
||||
badge: 'bg-info-50 text-info-600',
|
||||
dateLabel: 'Déclaré le',
|
||||
confirmDesc: "L'élément sera à nouveau disponible pour les autres.",
|
||||
serviceUrl: (t) => `/kontrib/${t}`,
|
||||
},
|
||||
kount_expense: {
|
||||
label: 'Dépense',
|
||||
badge: 'bg-warning-50 text-warning-700',
|
||||
dateLabel: 'Ajouté le',
|
||||
confirmDesc: 'La dépense sera supprimée du groupe.',
|
||||
serviceUrl: (t) => `/kount/${t}`,
|
||||
},
|
||||
}
|
||||
|
||||
function itemPreview(item: MyContributionItem): { label: string; detail: string } {
|
||||
const label = item.item_name ?? item.service_title
|
||||
if (item.type === 'contribution' && item.amount) {
|
||||
return { label, detail: `${parseFloat(item.amount)} € · ${item.service_title}` }
|
||||
}
|
||||
return { label, detail: item.item_name ? item.service_title : '' }
|
||||
}
|
||||
|
||||
function CancelActionModal({ item, onClose, onCancelled }: {
|
||||
item: MyContributionItem
|
||||
onClose: () => void
|
||||
onCancelled: (cancelToken: string) => void
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const cfg = TYPE_CONFIG[item.type]
|
||||
const preview = itemPreview(item)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
if (item.type === 'reservation') await cancelReservation(item.cancel_token)
|
||||
else if (item.type === 'contribution') await cancelContribution(item.cancel_token)
|
||||
else if (item.type === 'declaration') await cancelDeclaration(item.cancel_token)
|
||||
else await cancelExpense(item.cancel_token)
|
||||
onCancelled(item.cancel_token)
|
||||
onClose()
|
||||
} catch {
|
||||
setError("Une erreur est survenue. Veuillez réessayer.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} title={`Annuler ma ${cfg.label.toLowerCase()} ?`}>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-card px-4 py-3 -mt-2 mb-4">
|
||||
<p className="font-medium text-gray-900 text-sm">{preview.label}</p>
|
||||
{preview.detail && <p className="text-xs text-gray-500 mt-0.5">{preview.detail}</p>}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-6">{cfg.confirmDesc}</p>
|
||||
{error && <p className="text-sm text-danger-600 mb-4">{error}</p>}
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
className="btn-danger btn-sm flex-1"
|
||||
>
|
||||
{loading ? 'Annulation…' : 'Confirmer l\'annulation'}
|
||||
</button>
|
||||
<button onClick={onClose} className="btn-secondary btn-sm">Retour</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContributionCard({ item, onCancelled }: {
|
||||
item: MyContributionItem
|
||||
onCancelled: (cancelToken: string) => void
|
||||
}) {
|
||||
const [showCancel, setShowCancel] = useState(false)
|
||||
const cfg = TYPE_CONFIG[item.type]
|
||||
const serviceUrl = item.public_token ? cfg.serviceUrl(item.public_token) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
{showCancel && (
|
||||
<CancelActionModal
|
||||
item={item}
|
||||
onClose={() => setShowCancel(false)}
|
||||
onCancelled={onCancelled}
|
||||
/>
|
||||
)}
|
||||
<div className={`card relative flex flex-col gap-2 ${serviceUrl ? 'hover:border-gray-300 transition-colors' : ''}`}>
|
||||
{serviceUrl && <Link to={serviceUrl} state={{ from: '/hub' }} className="absolute inset-0 rounded-card" aria-label={`Voir ${item.item_name ?? item.service_title}`} />}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${cfg.badge}`}>{cfg.label}</span>
|
||||
{item.amount && <span className="text-xs text-gray-500 font-medium">{parseFloat(item.amount)} €</span>}
|
||||
</div>
|
||||
<p className="font-semibold text-gray-900 text-sm">{item.item_name ?? item.service_title}</p>
|
||||
{item.item_name && <p className="text-xs text-gray-400 truncate">{item.service_title}</p>}
|
||||
<div className="flex items-center justify-between gap-2 mt-0.5">
|
||||
<p className="text-xs text-gray-400">{cfg.dateLabel} {formatDate(item.created_at, 'long')}</p>
|
||||
<button
|
||||
onClick={() => setShowCancel(true)}
|
||||
className="relative z-10 text-xs text-danger-600 hover:text-danger-700 whitespace-nowrap shrink-0"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MyContributions() {
|
||||
const [items, setItems] = useState<MyContributionItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
getMyContributions().then(setItems).catch(() => {}).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleCancelled = (cancelToken: string) =>
|
||||
setItems(prev => prev.filter(i => i.cancel_token !== cancelToken))
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<div className="flex items-center justify-between gap-4 mb-6 pt-6">
|
||||
<h1 className="text-xl font-bold text-gray-900">Mes contributions</h1>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20"><Spinner /></div>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState message="Aucune contribution pour l'instant." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{items.map(item => (
|
||||
<ContributionCard key={item.cancel_token} item={item} onCancelled={handleCancelled} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function HubCard({ event, onDelete, onEdit, shareUrl }: {
|
||||
event: EventSummary
|
||||
onDelete: (id: string) => void
|
||||
onEdit?: (id: string) => void
|
||||
shareUrl?: string
|
||||
}) {
|
||||
return (
|
||||
<ResourceCard onDelete={() => onDelete(event.id)}>
|
||||
<div className="pr-5">
|
||||
<h2 className="card-title">{event.title}</h2>
|
||||
<div className="card-meta">
|
||||
{event.event_type && <span>{EVENT_TYPE_LABELS[event.event_type] ?? event.event_type}</span>}
|
||||
{event.event_date && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatDate(event.event_date)}
|
||||
</span>
|
||||
)}
|
||||
{event.location && <span className="text-xs text-gray-400 truncate">{event.location}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
<Link to={`/hub/${event.id}`} className="btn-primary btn-sm flex-1 text-center">Gérer</Link>
|
||||
{onEdit && <button onClick={() => onEdit(event.id)} className="btn-secondary btn-sm">Modifier</button>}
|
||||
{shareUrl && <ShareButton url={shareUrl} />}
|
||||
</div>
|
||||
</ResourceCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HubDashboard() {
|
||||
usePageTitle('Hub')
|
||||
return (
|
||||
<div>
|
||||
<ServiceDashboard<EventSummary>
|
||||
title="Mon Hub"
|
||||
createLabel="+ Nouveau projet"
|
||||
emptyMessage="Aucun projet pour l'instant"
|
||||
fetch={getEvents}
|
||||
onDelete={deleteEvent}
|
||||
confirmMessage={ev => `Supprimer le projet "${ev.title}" ? Cette action est irréversible.`}
|
||||
CreateModal={CreateHubModal}
|
||||
EditModal={EditHubModal}
|
||||
getShareUrl={ev => `${window.location.origin}/hub/p/${ev.share_token}`}
|
||||
renderCard={(ev, onDelete, onEdit, shareUrl) => <HubCard event={ev} onDelete={onDelete} onEdit={onEdit} shareUrl={shareUrl} />}
|
||||
/>
|
||||
<MyContributions />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
195
frontend/src/services/hub/pages/HubEdit.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
detachService, getEvent,
|
||||
updateServiceVisibility,
|
||||
type AttachedService, type EventDetail, type EventNotification,
|
||||
} from '@services/hub/api/hub'
|
||||
import { CalendarChipIcon, CommentBlock, CoOwnerPanel, LocationChipIcon, MetaChip, ServicePageHeader, SharePanel, Spinner } from '@shared/components'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import { EVENT_TYPE_ICONS } from '@shared/types'
|
||||
import { formatDate } from '@shared/utils/formatDate'
|
||||
import EditHubModal from '@services/hub/components/EditHubModal'
|
||||
import { services } from '@shared/config/services'
|
||||
import { groupByServiceType } from '@services/hub/utils'
|
||||
import { usePageTitle } from '@shared/hooks/usePageTitle'
|
||||
|
||||
// ── Notification feed ──────────────────────────────────────────────────────────
|
||||
|
||||
function NotificationFeed({ notifications }: { notifications: EventNotification[] }) {
|
||||
if (notifications.length === 0) return null
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-800 mb-3 text-sm">Activité</h3>
|
||||
<div className="space-y-2">
|
||||
{[...notifications].reverse().map(n => (
|
||||
<div key={n.id} className="flex items-start gap-2 text-xs text-gray-600">
|
||||
<span className="text-gray-400 shrink-0 mt-0.5">{formatDate(n.created_at)}</span>
|
||||
<span>{n.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Attached services grid ─────────────────────────────────────────────────────
|
||||
|
||||
function AttachedServicesPanel({ eventId, services: attached, onDetached, onVisibilityChange }: {
|
||||
eventId: string
|
||||
services: AttachedService[]
|
||||
onDetached: (id: string) => void
|
||||
onVisibilityChange: (svc: AttachedService) => void
|
||||
}) {
|
||||
if (attached.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-400 py-16 text-sm">
|
||||
Aucun service pour l'instant.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const grouped = groupByServiceType(attached, services)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{grouped.map(({ cfg, items }) => (
|
||||
<div key={cfg.id}>
|
||||
<div className="flex items-center gap-2 mb-3 text-gray-500">
|
||||
<span className="shrink-0">{cfg.icon}</span>
|
||||
<h3 className="text-sm font-semibold">{cfg.label}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map(svc => {
|
||||
const CardRenderer = cfg.renderAttachedCard
|
||||
if (CardRenderer) {
|
||||
return (
|
||||
<CardRenderer
|
||||
key={svc.id}
|
||||
resourceId={svc.resource_id}
|
||||
isVisible={svc.is_visible}
|
||||
onToggleVisibility={async () => onVisibilityChange(await updateServiceVisibility(eventId, svc.id, !svc.is_visible))}
|
||||
onDetach={async () => { await detachService(eventId, svc.id); onDetached(svc.id) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={svc.id} className="card-item flex items-center gap-3">
|
||||
<span className="flex-1 text-sm text-gray-800">{svc.service_type}</span>
|
||||
<button
|
||||
onClick={async () => onVisibilityChange(await updateServiceVisibility(eventId, svc.id, !svc.is_visible))}
|
||||
className={`text-xs px-2 py-0.5 rounded whitespace-nowrap ${svc.is_visible ? 'badge-green' : 'badge-gray'}`}
|
||||
>
|
||||
{svc.is_visible ? 'Visible' : 'Masqué'}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => { await detachService(eventId, svc.id); onDetached(svc.id) }}
|
||||
className="text-sm text-danger-600 hover:text-danger-700 px-1"
|
||||
>×</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function HubEdit() {
|
||||
const { user } = useAuth()
|
||||
const { eventId } = useParams<{ eventId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [event, setEvent] = useState<EventDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
usePageTitle(event ? `${event.title} — Hub` : undefined)
|
||||
const [ActiveServiceModal, setActiveServiceModal] = useState<ComponentType<{ onClose: () => void; eventId?: string }> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventId) return
|
||||
getEvent(eventId).then(setEvent).catch(() => setError('Projet introuvable')).finally(() => setLoading(false))
|
||||
}, [eventId])
|
||||
|
||||
if (loading) return <main className="page flex items-center justify-center h-32"><Spinner /></main>
|
||||
if (error || !event) return <main className="page text-center"><p className="text-gray-500">{error || 'Introuvable'}</p></main>
|
||||
|
||||
const publicUrl = `${window.location.origin}/hub/p/${event.share_token}`
|
||||
const eventActions = services.filter(s => s.active && s.eventActions?.length)
|
||||
.flatMap(s => s.eventActions!.map(action => ({ ...action, serviceIcon: s.icon, serviceLabel: s.label })))
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<ServicePageHeader
|
||||
title={event.title}
|
||||
titleIcon={event.event_type ? EVENT_TYPE_ICONS[event.event_type] : undefined}
|
||||
backPath="/hub"
|
||||
meta={<>
|
||||
{event.event_date && <MetaChip icon={<CalendarChipIcon />}>{formatDate(event.event_date, 'long')}</MetaChip>}
|
||||
{event.location && <MetaChip icon={<LocationChipIcon />}>{event.location}</MetaChip>}
|
||||
</>}
|
||||
description={event.description ?? undefined}
|
||||
onEdit={() => setShowEditModal(true)}
|
||||
/>
|
||||
|
||||
<div className="layout-service">
|
||||
<div className="service-main">
|
||||
{eventActions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{eventActions.map(action => (
|
||||
<button
|
||||
key={action.label}
|
||||
onClick={() => setActiveServiceModal(() => action.modal)}
|
||||
className="btn-primary btn-sm gap-1.5"
|
||||
>
|
||||
<span className="text-[11px] font-semibold opacity-60 leading-none">+</span>
|
||||
<span className="shrink-0">{action.serviceIcon}</span>
|
||||
{action.serviceLabel}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AttachedServicesPanel
|
||||
eventId={event.id}
|
||||
services={event.services}
|
||||
onDetached={id => setEvent(prev => prev ? { ...prev, services: prev.services.filter(s => s.id !== id) } : prev)}
|
||||
onVisibilityChange={svc => setEvent(prev => prev ? { ...prev, services: prev.services.map(s => s.id === svc.id ? svc : s) } : prev)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="service-right">
|
||||
<SharePanel url={publicUrl} label="Lien de le projet" downloadName={event.title} />
|
||||
<div className="card">
|
||||
<CoOwnerPanel resourceType="hub" resourceId={event.id} isOwner={event.user_id === user?.id} />
|
||||
</div>
|
||||
<NotificationFeed notifications={event.notifications} />
|
||||
<div className="card">
|
||||
<CommentBlock resourceType="hub" resourceId={event.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEditModal && (
|
||||
<EditHubModal
|
||||
item={event}
|
||||
onSaved={updated => setEvent(prev => prev ? { ...prev, ...updated } : prev)}
|
||||
onDeleted={() => navigate('/hub')}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
{ActiveServiceModal && (
|
||||
<ActiveServiceModal
|
||||
eventId={event.id}
|
||||
onClose={() => {
|
||||
setActiveServiceModal(null)
|
||||
getEvent(event.id).then(setEvent)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
87
frontend/src/services/hub/pages/HubNew.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { createEvent } from '@services/hub/api/hub'
|
||||
import { Alert } from '@shared/components'
|
||||
import { EVENT_TYPE_OPTIONS } from '@shared/types'
|
||||
import { extractApiError } from '@shared/utils/error'
|
||||
|
||||
export default function HubNew() {
|
||||
const navigate = useNavigate()
|
||||
const [title, setTitle] = useState('')
|
||||
const [eventType, setEventType] = useState('')
|
||||
const [eventDate, setEventDate] = useState('')
|
||||
const [location, setLocation] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const ev = await createEvent({
|
||||
title,
|
||||
event_type: eventType || null,
|
||||
event_date: eventDate || null,
|
||||
location: location || null,
|
||||
description: description || null,
|
||||
})
|
||||
navigate(`/hub/${ev.id}`)
|
||||
} catch (err) {
|
||||
setError(extractApiError(err, 'Erreur lors de la création'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page-sm py-10">
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-6">Nouveau projet</h1>
|
||||
<div className="card">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<Alert type="error" message={error} />
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Nom du projet *"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<select className="input" value={eventType} onChange={e => setEventType(e.target.value)}>
|
||||
{EVENT_TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
placeholder="Date"
|
||||
value={eventDate}
|
||||
onChange={e => setEventDate(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Lieu (optionnel)"
|
||||
value={location}
|
||||
onChange={e => setLocation(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
placeholder="Description (optionnel)"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<button type="submit" disabled={loading} className="btn-primary flex-1">
|
||||
{loading ? 'Création…' : 'Créer le projet'}
|
||||
</button>
|
||||
<button type="button" onClick={() => navigate('/hub')} className="btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||