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>
This commit is contained in:
Gautier Stefanini 2026-08-08 17:49:14 +00:00
commit 4162831f4e
337 changed files with 29911 additions and 0 deletions

31
.env.example Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,8 @@
tondomaine.com {
handle /api/* {
reverse_proxy localhost:8000
}
handle {
reverse_proxy localhost:3000
}
}

17
Dockerfile Normal file
View 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
View 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
View 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
View 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"}

View 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")

View 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")

View 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")

View 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)

View 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')

View 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))

View 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')

View 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')

View 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')

View 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')

View 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')

View 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')

View 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')

View 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')

View 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'")

View 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')

View 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')

View 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')

View 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')

View 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")

View 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)

View 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")

View 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")

View 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")

View 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
View file

50
api/config.py Normal file
View 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
View 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
View file

359
api/routers/auth.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

29
frontend/package.json Normal file
View 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"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View 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

View 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

View 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

View 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

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View 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
View 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

View 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

View 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" }
]
}

View 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
View 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
View 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
View 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>
)

View 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
}

View 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} />
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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)

View 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>
)
}

View 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>
)
}

View 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' },
],
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

Some files were not shown because too many files have changed in this diff Show more