diff --git a/.DS_Store b/.DS_Store index 3ba24ff..fad5716 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index b987436..2d4ca76 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ local_settings.py db.sqlite3 db.sqlite3-journal media +private_media # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ # in your Git repository. Update and uncomment the following line accordingly. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 295490e..a9d00c7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -409,7 +409,7 @@ ClubMembership(ClubScopedModel) # -> carries `club` status CharField (TextChoices: pending | active | lapsed | cancelled) fee_status CharField (TextChoices: unpaid | partial | paid | waived) signed_up_at DateTimeField (null) # when the member registered for the season - activated_at DateTimeField (null) # when membership became active (usually on payment) + activated_at DateTimeField (null) # when membership became active (admin approval only, never on payment alone) Meta: unique_together (club, member, season); ordering = ["-season__start_date", ...] ``` diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index c52bfbd..2340904 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -117,34 +117,30 @@ docker compose up -d --no-deps web ## Scheduled jobs -Four commands need to run on a schedule. Put them on the **host**, not in a container, and on -**exactly one node** when you have several — three nodes archiving the same club is three -emails to the same club. +Five jobs run on a schedule via **Celery Beat**, not host cron — see `rosterchief/settings.py` +(`CELERY_BEAT_SCHEDULE`) for the exact times and `features/jobs.py` for what each one does. +`worker` and `beat` are just the `web` image running a different command (see `compose.yaml`); +`worker` can scale to several containers, but run **exactly one `beat`** across the whole +deployment — it decides *when* a task fires, so two of them means every job runs twice (two +`archive_overdue_clubs` runs is two emails to the same club, the same "exactly one node" +reasoning the old crontab needed). -```cron -# Bill: remind club admins about outstanding platform fees. Dry-run by default, same as the -# archive job below — this one mails paying customers, so --commit is opt-in. Reminders go -# once per escalation level, not once per run, so a daily cron is not a daily email. -0 5 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py send_billing_reminders --commit +| Job | Cadence | What it does | +|---|---|---| +| `extend_event_series` | daily 03:00 | materialises recurring event occurrences so the calendar never runs dry | +| `renew_subscriptions` | daily 04:00 | opens the next billing period for clubs whose current one is running out | +| `send_billing_reminders` | daily 05:00 | emails club admins about outstanding platform fees, once per escalation level | +| `archive_overdue_clubs` | daily 06:00 | archives clubs unpaid past their grace period | +| `generate_seasons` | monthly, 1st 05:00 | generates the next 2 years of seasons for every active club | -# Bill: archive clubs unpaid past their grace period. -# Run it WITHOUT --commit for the first week and read the output. The flag exists because -# this switches off paying customers: a bad clock or a bad cron should cost you an email, -# not a morning of angry clubs. Since grace now runs from the period START rather than its -# end (see BILLING.md §3), this job is load-bearing in a way it never used to be — a club -# is archivable ~60 days after being invoiced, not ~410. Re-do the dry-run week. -0 6 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py archive_overdue_clubs --commit +Each task always acts (no `--dry-run`/`--commit` gate) — the same as the old crontab always +passing `--commit`. Run status (started, finished, success/failure, what it returned or +raised) is recorded in `features.models.JobRun` and shown on the control panel's **Jobs** +tab, which a crontab line mailing stderr on failure never gave us. -# Events: extend recurring series so the calendar never runs dry. -0 3 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py extend_event_series - -# Seasons: generate the next 2 years ahead for every active club. Safe to run repeatedly and -# needs no --commit — unlike archiving or resyncing, creating a future season row is additive -# and idempotent, so a monthly cadence just keeps every club's season list from ever running -# out. --resync exists on the same command for removing seasons that no longer match a club's -# settings, but that can delete rows, so it isn't run unattended here. -0 5 1 * * cd /srv/rosterchief && docker compose run --rm web python manage.py generate_seasons -``` +The `manage.py ` versions of these still exist unchanged, for manual/dry-run use +from a shell — see each command's own `--help` (`generate_seasons --resync`, for one, is +still CLI-only: it can delete rows, so it isn't something a beat schedule runs unattended). ## Maintenance mode @@ -155,16 +151,19 @@ Control panel → **Features → Maintenance mode**. While it is on: you with no way to turn it back off; - `/healthz` keeps answering on every host, or the load balancer would take the node out of rotation and the control panel with it; -- the **scheduled jobs stand down** — `archive_overdue_clubs`, `extend_event_series` and - `import_members_csv` refuse to run. +- the **scheduled jobs stand down** — the five Celery tasks in the table above, plus + `import_members_csv` when run by hand. `migrate` and `collectstatic` are deliberately **not** blocked. Maintenance is usually declared *in order* to run them, and a guard that stopped them would mean turning the mode off to do the work you turned it on for. -The scheduled jobs exit **non-zero** while the platform is closed, so cron will mail you. -That is intended: a job that silently skips itself is how a month of billing goes missing. If -you genuinely mean to run one during a window, pass `--ignore-maintenance`. +A Celery task raises loudly rather than skipping quietly while the platform is closed — that +is intended, a job that silently no-ops is how a month of billing goes missing — which +`worker` logs and, via `features/signals.py`, records as a `Failed` JobRun on the control +panel's **Jobs** tab. The `manage.py` version of each command still exits non-zero the same +way and accepts `--ignore-maintenance` for the rare case you genuinely mean to run one by +hand during a window. So a migration-heavy deploy looks like: @@ -456,6 +455,12 @@ via copy-on-write instead of each worker importing Django independently), plus t Postgres rows to come in lower than above — not yet re-measured, so treat the table as the shape of where memory goes rather than exact numbers on the current config. +The table also predates `worker` and `beat` (see "Scheduled jobs"): each is one more full +Django process, not re-measured yet either, but expect each to land in the same range as one +gunicorn worker above (~50–60 MB) since it's the same app import cost with none of gunicorn's +own overhead. `beat` additionally has essentially nothing to do between firing its five daily +tasks, so it's the cheapest process in the stack to run. + 2 GB would run it. 4 GB is the recommendation for three reasons, all of which are the kind of thing that bites at the worst moment: @@ -599,7 +604,7 @@ Nothing in the code changes. What changes is where the services live: | Cache / flags | `redis` container | managed Redis (or your existing one) | | Uploads | local disk | **S3 bucket** (`AWS_STORAGE_BUCKET_NAME`) | | Static files | WhiteNoise, in the image | unchanged — that is why WhiteNoise is there | -| Cron | host crontab | one node only | +| Scheduled jobs | `worker` + `beat` containers | `worker` on any/every node; **`beat` on exactly one** | | TLS | Caddy on the box | load balancer, or Caddy on each node | Drop `db` and `redis` from `compose.yaml`, point the URLs at the central services, and run diff --git a/Dockerfile b/Dockerfile index a857bb4..ad2bf87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -77,6 +77,8 @@ COPY --from=venv /app/.venv ./.venv COPY . . COPY --from=css /build/static/css/app.css ./static/css/app.css +COPY --from=css /build/static/css/controlpanel.css ./static/css/controlpanel.css +COPY --from=css /build/static/css/management.css ./static/css/management.css # collectstatic needs a settings module that imports: a throwaway key, never used at runtime. RUN DJANGO_SECRET_KEY=build-only-not-a-secret \ @@ -88,7 +90,7 @@ RUN DJANGO_SECRET_KEY=build-only-not-a-secret \ # app runs as rosterchief, not root. Existing image content (even an empty, correctly-owned # dir) is what a named volume copies its initial ownership from on first use. RUN useradd --system --uid 1000 rosterchief \ - && mkdir -p /app/media \ + && mkdir -p /app/media /app/private_media \ && chown -R rosterchief /app USER rosterchief diff --git a/assets/controlpanel.css b/assets/controlpanel.css new file mode 100644 index 0000000..c5c98c2 --- /dev/null +++ b/assets/controlpanel.css @@ -0,0 +1,1063 @@ +/* RosterChief control panel — a deliberately separate stylesheet. + * + * The control panel is a distinct surface (see design_handoff_rosterchief_platform/README.md): + * industrial, dark command bar over a light workspace, its own type system (Barlow / + * Barlow Condensed / IBM Plex Mono) and colour tokens, and — per that handoff — "never + * club-branded". It does not import assets/app.css and does not load daisyUI: sharing either + * would mean the panel inherits the club-facing app's theme variables and component shapes, + * which is exactly what "standalone" rules out. + * + * One wrinkle: a handful of templates the control panel renders — `controlpanel/_form_fields.html`, + * `templatetags/field.html`, and the `daisy`/`form_field` template tags in + * `controlpanel/templatetags/ui.py` — are also used by the sitewide login/MFA templates + * (templates/account/, templates/allauth/), so their *markup* is off limits here (changing it + * would reskin the login page too). Those templates emit daisyUI-shaped class names + * (`input`, `select`, `textarea`, `checkbox`, `toggle`, `file-input`, `btn`, `badge`, `card`, + * `table`, `modal`, `alert`, `avatar`, `progress`, `form-control`, `label`, `divide-y`, `link`) + * without daisyUI itself supplying their CSS (it isn't loaded here). So this file hand-rolls + * industrial-styled definitions for exactly that class vocabulary — same names, new look — + * scoped to controlpanel templates only via @source below, so nothing here reaches the + * pages built on assets/app.css. + */ +@import "tailwindcss"; + +@source "../controlpanel"; + +@theme { + --font-display: "Barlow Condensed", ui-sans-serif, system-ui, sans-serif; + --font-sans: "Barlow", ui-sans-serif, system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace; + + --color-ink: #0b1220; /* darkest -- command bar, sidebars, dark cards */ + --color-navy: #101e36; + --color-steel: #1b2b47; /* inset controls on dark */ + --color-hairline: #1e2b42; /* rules on dark surfaces */ + --color-paper: #f4f5f7; /* app/page background */ + --color-line: #e3e6eb; /* 1px borders on light */ + --color-rule: #eef0f3; /* table row dividers */ + --color-edge: #d6dae1; /* stronger light border -- control panel, inputs */ + --color-stroke: #c9cfd8; /* secondary-button border */ + --color-muted: #6c7787; /* secondary text */ + --color-dim: #8b95a4; /* tertiary text, inactive icons */ + --color-slate: #3a4658; /* body copy on light */ + --color-on-dark: #93a0b4; + --color-on-dark-dim: #7c8aa0; + --color-on-dark-faint: #5c6b85; + + /* Fixed platform brand red -- the control panel is never club-branded (per the design + handoff), so this is NOT the club.secondary_color CSS variable used elsewhere. */ + --color-club: #e4002b; + --color-club-dark: #b00021; + + --color-ice: #14b8e8; + --color-ice-ink: #04212c; + + --color-ok: #14a05a; + --color-ok-bg: #e6f6ee; + --color-ok-border: #bfe7d3; + --color-ok-text: #0c7a43; + + --color-warn: #f0a22e; + --color-warn-bg: #fff5e4; + --color-warn-border: #f6e0b8; + --color-warn-text: #9a6410; + --color-warn-deep: #7a4e08; + + --color-danger-bg: #fdecec; + --color-danger-border: #f5c9ce; + + --color-info-bg: #eaf7fc; + --color-info-border: #c3e7f4; + --color-info-text: #0a6f91; + + --color-row-sel: #fff7f8; /* selected table row */ + --color-row-focus: #f4f9ff; /* focused/active row */ + --color-row-warn: #fffdf6; /* row needing attention */ + --color-subhead: #f8f9fa; /* table header fill */ + + --radius-box: 0.25rem; /* control panel is square by intent -- 4px, not the usual rounded-xl */ +} + +/* --- fonts: self-hosted, same latin/latin-ext split as assets/app.css and the same + reasoning -- no request to Google's CDN on an EU club platform's admin surface. --- */ + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/barlow-latin-400-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/barlow-latin-ext-400-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("../fonts/barlow-latin-500-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("../fonts/barlow-latin-ext-500-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/barlow-latin-600-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/barlow-latin-ext-600-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../fonts/barlow-latin-700-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../fonts/barlow-latin-ext-700-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-600-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-ext-600-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-700-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-ext-700-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-800-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-ext-800-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-ext-400-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-500-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-ext-500-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-600-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-ext-600-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* @layer base, not a bare rule: a bare `body { background: ... }` is unlayered CSS, which + * beats every Tailwind utility class regardless of specificity -- including a `bg-ink` on + * itself (see controlpanel/templates/controlpanel/_auth_base.html), which would + * silently lose to this and always render var(--color-paper) instead. Layering it puts it + * where Tailwind's own base styles live, so a `bg-*`/`text-*` utility on wins as + * expected; this only supplies the default for whichever page doesn't set one. */ +@layer base { + body { + background: var(--color-paper); + color: var(--color-slate); + font-family: var(--font-sans); + } +} + +/* --- component layer: the daisyUI-shaped class vocabulary the shared form/message + templates render (controlpanel/templates/controlpanel/_form_fields.html, + controlpanel/templates/templatetags/field.html, controlpanel/templatetags/ui.py), plus + the same names used directly in controlpanel's own page templates. Hand-rolled rather + than @plugin "daisyui" -- see the file banner. --- */ + +@layer components { + /* Buttons -- 34-36px desktop per the handoff; controlpanel is desktop-only. */ + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + height: 2.125rem; + padding-inline: 0.875rem; + border-radius: var(--radius-box); + font-family: var(--font-display); + font-weight: 800; + font-size: 0.8125rem; + letter-spacing: 0.1em; + text-transform: uppercase; + background: var(--color-ink); + color: #fff; + border: 1px solid transparent; + cursor: pointer; + transition: opacity 0.15s ease; + white-space: nowrap; + } + + .btn:hover { + opacity: 0.85; + } + + .btn:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + .btn-primary { + background: var(--color-club); + color: #fff; + } + + .btn-success { + background: var(--color-ok); + color: #fff; + } + + .btn-warning { + background: var(--color-warn); + color: var(--color-ink); + } + + .btn-error { + background: var(--color-club-dark); + color: #fff; + } + + .btn-ghost { + background: transparent; + color: var(--color-ink); + } + + .btn-ghost:hover { + background: var(--color-rule); + opacity: 1; + } + + .btn-outline { + background: #fff; + color: var(--color-ink); + border-color: var(--color-stroke); + } + + .btn-outline:hover { + background: var(--color-subhead); + opacity: 1; + } + + .btn-outline.btn-primary { + background: #fff; + color: var(--color-club-dark); + border-color: var(--color-danger-border); + } + + .btn-outline.btn-error { + background: #fff; + color: var(--color-club-dark); + border-color: var(--color-danger-border); + } + + .btn-outline.btn-success { + background: #fff; + color: var(--color-ok-text); + border-color: var(--color-ok-border); + } + + /* accent -- allauth tags the passkey/security-key buttons "accent" (see + templates/allauth/elements/button.html and the manual btn-accent usages in + templates/account/login.html, password_change.html, mfa/authenticate.html). + Not one of the daisyUI semantic colours the rest of the app uses, so it gets + its own token: the ice cyan, which otherwise only appears on focus rings. */ + .btn-accent { + background: var(--color-ice); + color: var(--color-ice-ink); + } + + .btn-outline.btn-accent { + background: #fff; + color: var(--color-ice-ink); + border-color: var(--color-ice); + } + + /* A bare text link shaped like a button-slot -- allauth's button element renders + this for anything tagged "link" (a lower-emphasis alternative action). */ + .btn-link { + height: auto; + padding-inline: 0; + background: transparent; + color: var(--color-club-dark); + border-color: transparent; + text-transform: none; + letter-spacing: normal; + font-family: var(--font-sans); + font-weight: 600; + text-decoration: underline; + } + + .btn-link:hover { + opacity: 1; + text-decoration: none; + } + + .btn-soft { + background: color-mix(in oklab, currentColor 12%, transparent); + } + + .btn-square { + padding-inline: 0; + width: 2.125rem; + } + + .btn-sm { + height: 1.75rem; + padding-inline: 0.625rem; + font-size: 0.75rem; + } + + .btn-xs { + height: 1.5rem; + padding-inline: 0.5rem; + font-size: 0.6875rem; + gap: 0.25rem; + } + + /* Badges / status pills -- rounded-full per the handoff's "status pills" spec. */ + .badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.15rem 0.6rem; + border-radius: 999px; + font-family: var(--font-display); + font-weight: 700; + font-size: 0.6875rem; + letter-spacing: 0.08em; + text-transform: uppercase; + background: var(--color-rule); + color: var(--color-slate); + border: 1px solid var(--color-line); + white-space: nowrap; + } + + .badge-sm { + font-size: 0.625rem; + padding: 0.1rem 0.45rem; + } + + .badge-xs { + font-size: 0.5625rem; + padding: 0.05rem 0.35rem; + } + + .badge-success { + background: var(--color-ok-bg); + color: var(--color-ok-text); + border-color: var(--color-ok-border); + } + + .badge-error { + background: var(--color-danger-bg); + color: var(--color-club-dark); + border-color: var(--color-danger-border); + } + + .badge-warning { + background: var(--color-warn-bg); + color: var(--color-warn-text); + border-color: var(--color-warn-border); + } + + .badge-info { + background: var(--color-info-bg); + color: var(--color-info-text); + border-color: var(--color-info-border); + } + + .badge-ghost, + .badge-outline { + background: #fff; + color: var(--color-muted); + border-color: var(--color-edge); + } + + .badge-neutral { + background: var(--color-ink); + color: #fff; + border-color: var(--color-ink); + } + + /* Cards -- square by intent (4px radius), hairline border, no shadow. */ + .card { + background: #fff; + border: 1px solid var(--color-edge); + border-radius: var(--radius-box); + } + + .card-body { + padding: 1.125rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .card-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-display); + font-weight: 800; + font-size: 0.9375rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-ink); + } + + /* The action row a card/form/panel puts its buttons in -- allauth's own form and + panel elements (templates/allauth/elements/form.html, panel.html) render this + unconditionally, pairing it with justify-end plus a margin/padding utility that + varies per call site. */ + .card-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + } + + /* Tables -- CSS-grid-flavoured look via plain : subhead fill, mono headings, + hairline row dividers. */ + .table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; + } + + .table thead tr { + background: var(--color-subhead); + border-bottom: 1px solid var(--color-line); + } + + .table thead th { + padding: 0.5rem 0.875rem; + text-align: left; + font-family: var(--font-mono); + font-size: 0.6875rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-muted); + font-weight: 500; + } + + .table tbody tr { + border-bottom: 1px solid var(--color-rule); + } + + .table tbody tr:last-child { + border-bottom: none; + } + + .table tbody td { + padding: 0.625rem 0.875rem; + color: var(--color-ink); + vertical-align: middle; + } + + /* Alerts -- left-accented banner, per the handoff's severity-bar language. */ + .alert { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.875rem 1rem; + border-radius: var(--radius-box); + border: 1px solid var(--color-line); + background: #fff; + font-size: 0.875rem; + } + + .alert-soft { + background: var(--color-warn-bg); + } + + .alert-error { + background: var(--color-danger-bg); + border-color: var(--color-danger-border); + color: var(--color-club-dark); + } + + .alert-warning { + background: var(--color-warn-bg); + border-color: var(--color-warn-border); + color: var(--color-warn-deep); + } + + .alert-success { + background: var(--color-ok-bg); + border-color: var(--color-ok-border); + color: var(--color-ok-text); + } + + .alert-info { + background: var(--color-info-bg); + border-color: var(--color-info-border); + color: var(--color-info-text); + } + + /* Avatars -- fallback initials block; a real club.logo uses the branch. */ + .avatar > div { + border-radius: 999px; + overflow: hidden; + } + + .avatar-placeholder > div { + display: flex; + align-items: center; + justify-content: center; + background: var(--color-ink); + color: #fff; + font-family: var(--font-display); + font-weight: 800; + } + + /* Progress bars. */ + progress.progress { + appearance: none; + width: 100%; + height: 0.375rem; + border-radius: 999px; + overflow: hidden; + background: var(--color-rule); + border: none; + } + + progress.progress::-webkit-progress-bar { + background: var(--color-rule); + } + + progress.progress::-webkit-progress-value { + background: var(--color-ink); + } + + progress.progress::-moz-progress-bar { + background: var(--color-ink); + } + + progress.progress-success::-webkit-progress-value { + background: var(--color-ok); + } + + progress.progress-success::-moz-progress-bar { + background: var(--color-ok); + } + + progress.progress-warning::-webkit-progress-value { + background: var(--color-warn); + } + + progress.progress-warning::-moz-progress-bar { + background: var(--color-warn); + } + + progress.progress-error::-webkit-progress-value { + background: var(--color-club); + } + + progress.progress-error::-moz-progress-bar { + background: var(--color-club); + } + + /* Form controls -- rounded-lg per the handoff's field spec (a touch softer than the + square 4px chrome, matching "buttons and inputs rounded-lg"). */ + .input, + .select, + .textarea, + .file-input { + width: 100%; + border: 1px solid var(--color-edge); + border-radius: 0.5rem; + background: #fff; + color: var(--color-ink); + font-family: var(--font-sans); + font-size: 0.875rem; + padding: 0.5rem 0.75rem; + } + + /* .input and .select get an explicit, identical height rather than relying on padding + plus the browser's own line-height to land on the same box: a , so the same padding alone does not guarantee the + same rendered height. appearance: none plus a hand-drawn arrow replaces the native + one, since that reserved space was part of what caused the mismatch. */ + .input, + .select { + height: 2.125rem; + } + + .select { + padding-right: 2rem; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%236c7787' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 7.5L10 12.5L15 7.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.6rem center; + background-size: 1rem; + } + + .input:focus, + .select:focus, + .textarea:focus { + outline: 2px solid var(--color-ice); + outline-offset: -1px; + border-color: var(--color-ice); + } + + .textarea { + height: auto; + min-height: 6rem; + } + + .input-error, + .select-error, + .textarea-error { + border-color: var(--color-club); + color: var(--color-club-dark); + } + + /* Taller variant for a code-entry input that has fallen back out of the boxed + .otp layout below (a recovery code is longer than the boxes fit). */ + .input-lg { + height: 3rem; + font-family: var(--font-mono); + font-size: 1.125rem; + letter-spacing: 0.2em; + text-align: center; + } + + /* Boxed one-time-code input -- recreates daisyUI's `otp`/`otp-lg` layout (a row of + character boxes with the real, accessible sitting on top, invisible but still + focusable/typeable, so a tap or click still lands on it) without daisyUI. The boxes are + drawn as plain sibling s -- see templates/allauth/elements/fields.html, which + keeps the exact daisyUI-shaped markup/class names because the same template also + renders on a club subdomain, where daisyUI itself (assets/app.css) still styles them. + A letter-spacing/text-indent overlay (faking alignment by spacing the real input's own + glyphs to match the box pitch) turned out too font-metric-fragile to land reliably -- + drifted more with every character typed, in a way that resisted two rounds of tuning. + Instead the real input's text is made invisible (`color: transparent`, only the caret + shows) and controlpanel/templates/controlpanel/_auth_base.html's `data-otp` script + writes each typed character into its own 's textContent -- exact by construction, + no font metrics involved. */ + .otp { + position: relative; + display: inline-flex; + gap: 0.5rem; + } + + .otp span { + display: flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 3rem; + border: 1px solid var(--color-edge); + border-radius: 0.5rem; + background: #fff; + font-family: var(--font-mono); + font-size: 1.25rem; + color: var(--color-ink); + } + + .otp:focus-within span { + border-color: var(--color-ice); + } + + .otp input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + border: none; + outline: none; + background: transparent; + color: transparent; + caret-color: var(--color-ink); + } + + .otp-lg span { + width: 3.25rem; + height: 3.5rem; + font-size: 1.375rem; + } + + .checkbox { + width: 1.125rem; + height: 1.125rem; + border-radius: 0.25rem; + border: 1.5px solid var(--color-stroke); + accent-color: var(--color-club); + } + + .toggle { + width: 2.5rem; + height: 1.375rem; + border-radius: 999px; + accent-color: var(--color-ok); + } + + .form-control { + display: flex; + flex-direction: column; + } + + /* Wraps a .label-text (+ an optional trailing badge/alt), a row by default -- see + templates/allauth/elements/field.html and fields.html, whose checkbox layout + pairs it with justify-start/gap-3 utilities for the checkbox-then-text order. */ + .label { + display: flex; + align-items: center; + gap: 0.5rem; + padding-bottom: 0.375rem; + } + + .label-text { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-slate); + } + + .label-text-alt { + font-size: 0.75rem; + color: var(--color-muted); + } + + /* Links inside table rows / prose. */ + .link { + color: var(--color-club-dark); + text-decoration: none; + } + + .link-hover:hover { + text-decoration: underline; + } + + /* A plain horizontal rule between sections -- allauth's `hr` element + (templates/allauth/elements/hr.html) renders a bare `
`. */ + .divider { + margin: 1.5rem 0; + border-top: 1px solid var(--color-line); + } + + /* A native
/ disclosure -- allauth's `details` element + (templates/allauth/elements/details.html), not currently exercised by any of + the pages this redesign covers, restyled defensively since it is shared + low-level allauth plumbing like the rest of templates/allauth/elements/. The + browser's own default marker on is left alone rather than replaced. */ + .collapse { + border: 1px solid var(--color-line); + border-radius: var(--radius-box); + overflow: hidden; + } + + .collapse-title { + padding: 0.75rem 1rem; + cursor: pointer; + font-weight: 600; + color: var(--color-ink); + } + + .collapse-content { + padding: 0 1rem 1rem; + color: var(--color-slate); + } + + .border-base-300 { + border-color: var(--color-line); + } + + /* The command bar's account menu (controlpanel/base.html) -- a native +
/ disclosure, so the only CSS it actually needs is hiding the + browser's own marker triangle; position/spacing are plain utilities at the call + site. */ + .account-menu > summary { + list-style: none; + } + + .account-menu > summary::-webkit-details-marker { + display: none; + } + + /* --- daisyUI's semantic "base"/"content" vocabulary, aliased rather than renamed. + templates/allauth/elements/*.html, templates/allauth/layouts/base.html and + templates/403.html/maintenance.html are shared with the club-branded skin + (assets/app.css + real daisyUI, untouched, still in charge there), so they keep + daisyUI's own class names rather than controlpanel-specific ones. These give + those exact names an industrial-appropriate meaning for pages rendered through + this stylesheet; shadow-xl/shadow and .prose are deliberately left undefined -- + no shadow and no auto-prose-spacing is the point of the square, hairline-bordered + look here, not a gap. --- */ + .bg-base-100 { + background: #fff; + } + + .border-base-content { + border-color: var(--color-line); + } + + .border-base-content\/20 { + border-color: var(--color-line); + } + + .text-base-content { + color: var(--color-slate); + } + + .text-base-content\/70 { + color: var(--color-muted); + } + + .text-base-content\/80 { + color: var(--color-slate); + } + + .text-error, + .text-error-content { + color: var(--color-club-dark); + } + + .text-warning { + color: var(--color-warn-deep); + } + + .text-neutral-content { + color: #fff; + } + + /* Divide-y utility (used by label/value stat lists). */ + .divide-y > * + * { + border-top: 1px solid var(--color-rule); + } + + /* Native -based modals -- see controlpanel/templates/controlpanel/_modal_form.html + and _confirm_modal.html. No JS beyond the inline .showModal()/method=dialog already in + those templates; this only supplies the visual treatment daisyUI would otherwise. */ + dialog.modal { + margin: auto; + padding: 0; + border: none; + border-radius: var(--radius-box); + background: transparent; + max-width: none; + max-height: none; + } + + dialog.modal::backdrop { + background: rgba(11, 18, 32, 0.55); + } + + dialog.modal .modal-box { + background: #fff; + border: 1px solid var(--color-edge); + border-radius: var(--radius-box); + padding: 1.5rem; + width: 32rem; + max-width: 91vw; + max-height: 85vh; + overflow-y: auto; + } + + dialog.modal.modal-start { + margin: 0 0 0 auto; + height: 100vh; + max-height: none; + } + + dialog.modal.modal-start .modal-box { + height: 100%; + max-height: none; + border-radius: 0; + border-left: 1px solid var(--color-edge); + } + + .modal-action { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1.5rem; + } + + .modal-backdrop { + display: none; + } + + /* The club crest mark -- fallback for every club until a real Club.logo is uploaded. + A plain circle, same convention as .avatar-placeholder elsewhere in this file. Size + and colour are set per usage site (bg-* + explicit width/height utilities); this only + supplies the shape. */ + .crest { + border-radius: 999px; + } + + /* A club's own primary_color, applied as a ring around its uploaded logo -- scoped to a + locally-set --color-primary custom property (see club_detail.html) rather than a + page-wide override, because the control panel itself is never club-branded. */ + .ring-primary { + box-shadow: + 0 0 0 2px #fff, + 0 0 0 4px var(--color-primary, var(--color-edge)); + } + + /* Sidebar menu (mobile nav drawer only -- see base.html). */ + .menu li a { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + font-family: var(--font-display); + font-weight: 700; + font-size: 0.875rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-slate); + } + + .menu li a:hover { + background: var(--color-rule); + } + + .menu li a.menu-active { + background: var(--color-club); + color: #fff; + } + + .menu-title { + padding: 0.5rem 0.75rem 0.25rem; + font-family: var(--font-mono); + font-size: 0.6875rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-dim); + } +} + +/* SubscriptionForm.notes (Change plan / Start billing modal), PlanForm.description (New/Edit + * plan modal) and FlagForm.note (Edit feature modal): templatetags/field.html's textarea + * branch hard-codes an `h-72` (18rem) utility class on every textarea with no per-field way + * to pass rows/height, so the only remaining hook is the rendered `name` attribute -- unique + * to these fields within the control panel (no other controlpanel form uses "notes", + * "description" or "note"). Kept unlayered, rather than inside `@layer components` above, so + * it beats the `.h-72` utility class regardless of selector specificity: Tailwind's utilities + * layer is declared after components and always wins at equal or lower specificity, and only + * an unlayered rule is guaranteed to outrank it. Every other textarea in the panel + * (maintenance message, etc.) is untouched, so this does not shrink anything else. */ +textarea[name="notes"], +textarea[name="description"], +textarea[name="note"] { + height: 5rem; +} diff --git a/assets/management.css b/assets/management.css new file mode 100644 index 0000000..6c5446a --- /dev/null +++ b/assets/management.css @@ -0,0 +1,1063 @@ +/* RosterChief club management -- a deliberately separate stylesheet from both + * assets/app.css (the old daisyUI skin, still used by the auth/public chain via + * _club_base.html) and assets/controlpanel.css (the platform-staff surface, which is + * explicitly never club-branded). + * + * Management IS club-branded (see design_handoff_rosterchief_platform/README.md, "Club + * theming"): --color-club and --color-club-dark below resolve through --tenant-club / + * --tenant-club-dark custom properties, which management/templates/management/base.html + * sets per-request from Club.secondary_color -- everything else (structure, type, + * neutrals, status colours, table chrome, form fields) is locked platform-wide, same + * rule the design doc gives for the public site. + * + * Same "hand-roll the daisyUI-shaped class vocabulary" pattern as controlpanel.css: a + * few shared templates (`_form_fields.html`, `templatetags/field.html`, + * `controlpanel/templatetags/ui.py`'s `daisy`/`form_field`) render class names + * (`input`, `select`, `textarea`, `checkbox`, `toggle`, `btn`, `badge`, `card`, `table`, + * `modal`, `alert`, `avatar`, `progress`, `form-control`, `label`, `divide-y`, `link`) + * without daisyUI itself supplying their CSS here (it isn't loaded here either) -- see + * management/templates/management/_form_fields.html for management's own copy of that + * shared machinery (forked, not reused, so the login/MFA pages stay untouched -- same + * reasoning as controlpanel's fork, see that file's own banner comment). + */ +@import "tailwindcss"; + +@source "../management"; + +@theme { + --font-display: "Barlow Condensed", ui-sans-serif, system-ui, sans-serif; + --font-sans: "Barlow", ui-sans-serif, system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace; + + --color-ink: #0b1220; + --color-navy: #101e36; + --color-steel: #1b2b47; + --color-hairline: #1e2b42; + --color-paper: #f4f5f7; + --color-line: #e3e6eb; + --color-rule: #eef0f3; + --color-edge: #d6dae1; + --color-stroke: #c9cfd8; + --color-muted: #6c7787; + --color-dim: #8b95a4; + --color-slate: #3a4658; + --color-on-dark: #93a0b4; + --color-on-dark-dim: #7c8aa0; + --color-on-dark-faint: #5c6b85; + + /* Club-themeable: see the file banner. Falls back to the platform red so a club + that hasn't set a secondary_color yet still gets a sane, branded-looking accent. */ + --color-club: var(--tenant-club, #e4002b); + --color-club-dark: var(--tenant-club-dark, #b00021); + /* base.html sets --tenant-club-content from Club.secondary_content_color (already + WCAG-luminance-computed black/white, see Club._content_color_for) -- e.g. the + active nav item's own text, which sits on --color-club and can't assume white + reads on every club's chosen colour. */ + --color-club-content: var(--tenant-club-content, #ffffff); + + /* The sidebar's own background/foreground -- base.html sets --tenant-sidebar-bg/fg + from Club.primary_color/primary_content_color when the club has set one, same + WCAG black-or-white choice as club-content above. Falls back to the original + fixed dark-ink look otherwise. Derived shades (dim/faint/hairline/chip) are + color-mix'd toward the background rather than kept as a separate light/dark + palette -- that self-corrects for either direction (dark-on-light primary colour, + or light-on-dark) instead of needing two hand-tuned scales. */ + --color-sidebar-bg: var(--tenant-sidebar-bg, var(--color-ink)); + --color-sidebar-fg: var(--tenant-sidebar-fg, #ffffff); + --color-sidebar-fg-dim: color-mix(in srgb, var(--color-sidebar-fg) 68%, var(--color-sidebar-bg)); + --color-sidebar-fg-faint: color-mix(in srgb, var(--color-sidebar-fg) 38%, var(--color-sidebar-bg)); + --color-sidebar-hairline: color-mix(in srgb, var(--color-sidebar-fg) 16%, var(--color-sidebar-bg)); + --color-sidebar-chip: color-mix(in srgb, var(--color-sidebar-fg) 24%, var(--color-sidebar-bg)); + + --color-ok: #14a05a; + --color-ok-bg: #e6f6ee; + --color-ok-border: #bfe7d3; + --color-ok-text: #0c7a43; + + --color-warn: #f0a22e; + --color-warn-bg: #fff5e4; + --color-warn-border: #f6e0b8; + --color-warn-text: #9a6410; + --color-warn-deep: #7a4e08; + + --color-danger-bg: #fdecec; + --color-danger-border: #f5c9ce; + + --color-info: #14b8e8; + --color-info-bg: #eaf7fc; + --color-info-border: #c3e7f4; + --color-info-text: #0a6f91; + + --color-row-sel: #fff7f8; + --color-row-focus: #f4f9ff; + --color-row-warn: #fffdf6; + --color-subhead: #f8f9fa; + + /* Calendar resource colour (training rink, in the design's Season calendar screen) -- + management-only, controlpanel.css has no equivalent. */ + --color-violet: #7c5cfc; + + --radius-box: 0.75rem; /* management cards/buttons are rounded-xl, not controlpanel's square 4px */ +} + +/* --- fonts: identical set to controlpanel.css, same self-hosted files (shared static/fonts/ + directory -- one download, two stylesheets). See that file for the licensing/privacy + rationale (no Google Fonts CDN request). --- */ + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/barlow-latin-400-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/barlow-latin-ext-400-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("../fonts/barlow-latin-500-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("../fonts/barlow-latin-ext-500-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/barlow-latin-600-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/barlow-latin-ext-600-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../fonts/barlow-latin-700-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../fonts/barlow-latin-ext-700-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-600-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-ext-600-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-700-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-ext-700-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-800-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-ext-800-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-ext-400-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-500-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-ext-500-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-600-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-ext-600-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@layer base { + body { + background: var(--color-paper); + color: var(--color-slate); + font-family: var(--font-sans); + } +} + +/* --- component layer: same daisyUI-shaped class vocabulary as controlpanel.css, rounded + -xl (0.75rem) rather than controlpanel's square 4px -- see design tokens table, + "Radius: mobile cards rounded-[14px]; ... desktop cards rounded-xl". --- */ + +@layer components { + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + height: 2.25rem; + padding-inline: 1rem; + border-radius: var(--radius-box); + font-family: var(--font-display); + font-weight: 800; + font-size: 0.8125rem; + letter-spacing: 0.08em; + text-transform: uppercase; + background: var(--color-ink); + color: #fff; + border: 1px solid transparent; + cursor: pointer; + transition: opacity 0.15s ease; + white-space: nowrap; + } + + .btn:hover { + opacity: 0.85; + } + + .btn:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + .btn-primary { + background: var(--color-club); + color: #fff; + } + + .btn-success { + background: var(--color-ok); + color: #fff; + } + + .btn-warning { + background: var(--color-warn); + color: var(--color-ink); + } + + .btn-error { + background: var(--color-club-dark); + color: #fff; + } + + /* "ice" in the mobile/coach-app design tokens -- same #14B8E8 as --color-info here, + used e.g. for the Sign-up page's oldest-waiting row accent (signup_list.html). */ + .btn-info { + background: var(--color-info); + color: #fff; + } + + .btn-ghost { + background: transparent; + color: var(--color-ink); + } + + .btn-ghost:hover { + background: var(--color-rule); + opacity: 1; + } + + .btn-outline { + background: #fff; + color: var(--color-ink); + border-color: var(--color-stroke); + } + + .btn-outline:hover { + background: var(--color-subhead); + opacity: 1; + } + + .btn-outline.btn-primary { + background: #fff; + color: var(--color-club-dark); + border-color: var(--color-danger-border); + } + + .btn-outline.btn-error { + background: #fff; + color: var(--color-club-dark); + border-color: var(--color-danger-border); + } + + .btn-outline.btn-success { + background: #fff; + color: var(--color-ok-text); + border-color: var(--color-ok-border); + } + + .btn-outline.btn-info { + background: #fff; + color: var(--color-info-text); + border-color: var(--color-info-border); + } + + .btn-sm { + height: 1.875rem; + padding-inline: 0.75rem; + font-size: 0.75rem; + } + + .btn-xs { + height: 1.5rem; + padding-inline: 0.5rem; + font-size: 0.6875rem; + gap: 0.25rem; + } + + .btn-square { + padding-inline: 0; + width: 2.25rem; + } + + /* The design's own D-series badges/chips (D8's "Draft"/status pills aside) mostly run + small cornered boxes, not a full pill -- e.g. D2's team tags (U16/U18/Women, 8px) + and D8's filter chips (All 42/Overdue, 6px). A flat 999px read as noticeably rounder + than either once actually rendered at badge height, so this is a fixed value rather + than reusing --radius-box (0.75rem/12px), which is tuned for cards, not a ~22px chip. */ + .badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.15rem 0.6rem; + border-radius: 6px; + font-family: var(--font-display); + font-weight: 700; + font-size: 0.6875rem; + letter-spacing: 0.08em; + text-transform: uppercase; + background: var(--color-rule); + color: var(--color-slate); + border: 1px solid var(--color-line); + white-space: nowrap; + } + + .badge-sm { + font-size: 0.625rem; + padding: 0.1rem 0.45rem; + } + + .badge-xs { + font-size: 0.5625rem; + padding: 0.05rem 0.35rem; + } + + .badge-success { + background: var(--color-ok-bg); + color: var(--color-ok-text); + border-color: var(--color-ok-border); + } + + .badge-error { + background: var(--color-danger-bg); + color: var(--color-club-dark); + border-color: var(--color-danger-border); + } + + .badge-warning { + background: var(--color-warn-bg); + color: var(--color-warn-text); + border-color: var(--color-warn-border); + } + + .badge-info { + background: var(--color-info-bg); + color: var(--color-info-text); + border-color: var(--color-info-border); + } + + .badge-ghost, + .badge-outline { + background: #fff; + color: var(--color-muted); + border-color: var(--color-edge); + } + + .badge-neutral { + background: var(--color-ink); + color: #fff; + border-color: var(--color-ink); + } + + .card { + background: #fff; + border: 1px solid var(--color-line); + border-radius: var(--radius-box); + } + + .card-body { + padding: 1.125rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .card-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-display); + font-weight: 800; + font-size: 1rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-ink); + } + + .table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + } + + .table thead tr { + background: var(--color-subhead); + border-bottom: 1px solid var(--color-line); + } + + .table thead th { + padding: 0.5rem 1rem; + text-align: left; + font-family: var(--font-display); + font-weight: 700; + font-size: 0.6875rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-muted); + } + + .table tbody tr { + border-bottom: 1px solid var(--color-rule); + } + + .table tbody tr:last-child { + border-bottom: none; + } + + /* Scoped to .cursor-pointer, not every .table row -- most tables in the app + aren't themselves clickable (actions live in per-row buttons instead), and a + row-wide hover there would suggest an interaction that isn't there. Currently + just the Sign-up page's queue (signup_list.html), whose rows open the detail + drawer on click. --color-subhead, not --color-row-focus: that token is also + used as signup_list.html's persistent "oldest pending" row background, and + sharing it made that row look permanently hovered. */ + .table tbody tr.cursor-pointer:hover { + background: var(--color-subhead); + } + + .table tbody td { + padding: 0.75rem 1rem; + color: var(--color-ink); + vertical-align: middle; + } + + .alert { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.875rem 1rem; + border-radius: var(--radius-box); + border: 1px solid var(--color-line); + background: #fff; + font-size: 0.875rem; + } + + .alert-error { + background: var(--color-danger-bg); + border-color: var(--color-danger-border); + color: var(--color-club-dark); + } + + .alert-warning { + background: var(--color-warn-bg); + border-color: var(--color-warn-border); + color: var(--color-warn-deep); + } + + .alert-success { + background: var(--color-ok-bg); + border-color: var(--color-ok-border); + color: var(--color-ok-text); + } + + .alert-info { + background: var(--color-info-bg); + border-color: var(--color-info-border); + color: var(--color-info-text); + } + + .avatar > div { + border-radius: 999px; + overflow: hidden; + } + + .avatar-placeholder > div { + display: flex; + align-items: center; + justify-content: center; + background: var(--color-steel); + color: #fff; + font-family: var(--font-display); + font-weight: 800; + } + + progress.progress { + appearance: none; + width: 100%; + height: 0.375rem; + border-radius: 999px; + overflow: hidden; + background: var(--color-rule); + border: none; + } + + progress.progress::-webkit-progress-bar { + background: var(--color-rule); + } + + progress.progress::-webkit-progress-value { + background: var(--color-ink); + } + + progress.progress::-moz-progress-bar { + background: var(--color-ink); + } + + progress.progress-success::-webkit-progress-value { + background: var(--color-ok); + } + + progress.progress-success::-moz-progress-bar { + background: var(--color-ok); + } + + progress.progress-warning::-webkit-progress-value { + background: var(--color-warn); + } + + progress.progress-warning::-moz-progress-bar { + background: var(--color-warn); + } + + progress.progress-error::-webkit-progress-value { + background: var(--color-club); + } + + progress.progress-error::-moz-progress-bar { + background: var(--color-club); + } + + .input, + .select, + .textarea, + .file-input { + width: 100%; + border: 1px solid var(--color-edge); + border-radius: 0.5rem; + background: #fff; + color: var(--color-ink); + font-family: var(--font-sans); + font-size: 0.875rem; + padding: 0.5rem 0.75rem; + height: 2.25rem; + } + + .select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236C7787' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.6rem center; + background-size: 1rem; + padding-right: 2rem; + } + + /* The browser's own "Choose File" control has no author styling at all, so it + renders as a second, differently-chromed box nested inside .file-input's own + border -- restyle the button itself as a small pill so there's one field + boundary, not two. The trailing "no file chosen" text is UA shadow content + (can't be restyled beyond color, which it inherits from .file-input). */ + .file-input { + /* display:flex/align-items:center alone don't reliably center a file + input's native button+label in every browser -- Safari lays that out + as shadow content that ignores the host's flex alignment. Centering it + through the box model instead (top/bottom padding sized to leave equal + space above and below the button below) works regardless: 2.25rem box + - 2 * 1px border - 1.5rem button, split evenly. */ + display: flex; + align-items: center; + padding-block: 0.3125rem; + color: var(--color-dim); + } + + .file-input::file-selector-button, + .file-input::-webkit-file-upload-button { + /* Safari ignores author background/border-radius on this pseudo-element + and keeps drawing its own native bezeled button unless appearance is + reset first -- everything below was silently a no-op in Safari without + this line (same family of "Safari overrides author styling on a native + control" issue as the sign-up drawer's corners). */ + appearance: none; + -webkit-appearance: none; + height: 1.5rem; + padding-inline: 0.75rem; + margin-right: 0.75rem; + border: none; + /* Not 999px: that pill radius is reserved elsewhere (.avatar, .toggle, + .progress, .crest) for genuinely round/pill shapes -- every real .btn + uses var(--radius-box) (rounded-xl), so a pill here read as a toggle + chip rather than a button. */ + border-radius: 0.375rem; + background: var(--color-ink); + color: #fff; + font-family: var(--font-display); + font-weight: 800; + font-size: 0.6875rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: opacity 0.15s ease; + } + + .file-input:hover::file-selector-button, + .file-input:hover::-webkit-file-upload-button { + opacity: 0.85; + } + + .input:focus, + .select:focus, + .textarea:focus, + .file-input:focus-within { + outline: 2px solid var(--color-club); + outline-offset: -1px; + border-color: var(--color-club); + } + + .textarea { + height: auto; + min-height: 6rem; + } + + .input-error, + .select-error, + .textarea-error { + border-color: var(--color-club); + color: var(--color-club-dark); + } + + /* Taller variant for a code-entry input that has fallen back out of the boxed + .otp layout below (a recovery code is longer than the boxes fit). Same pair as + controlpanel.css's own copy -- see that file's .otp comment for why the real + input stays invisible-but-focusable rather than trying to align its own glyphs + with the box pitch via CSS alone. */ + .input-lg { + height: 3rem; + font-family: var(--font-mono); + font-size: 1.125rem; + letter-spacing: 0.2em; + text-align: center; + } + + .otp { + position: relative; + display: inline-flex; + gap: 0.5rem; + } + + .otp span { + display: flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 3rem; + border: 1px solid var(--color-edge); + border-radius: 0.5rem; + background: #fff; + font-family: var(--font-mono); + font-size: 1.25rem; + color: var(--color-ink); + } + + .otp:focus-within span { + border-color: var(--color-club); + } + + .otp input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + border: none; + outline: none; + background: transparent; + color: transparent; + caret-color: var(--color-ink); + } + + .otp-lg span { + width: 3.25rem; + height: 3.5rem; + font-size: 1.375rem; + } + + .checkbox { + width: 1.125rem; + height: 1.125rem; + border-radius: 0.25rem; + border: 1.5px solid var(--color-stroke); + accent-color: var(--color-club); + } + + .toggle { + width: 2.5rem; + height: 1.375rem; + border-radius: 999px; + accent-color: var(--color-ok); + } + + .form-control { + display: flex; + flex-direction: column; + } + + .label-text { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-slate); + } + + .label-text-alt { + font-size: 0.75rem; + color: var(--color-muted); + } + + .link { + color: var(--color-club-dark); + text-decoration: none; + } + + .link-hover:hover { + text-decoration: underline; + } + + .divide-y > * + * { + border-top: 1px solid var(--color-rule); + } + + /* Native -based modals -- see management/templates/management/_modal_form.html + and _confirm_modal.html (forked from controlpanel's, same reasoning). */ + dialog.modal { + margin: auto; + padding: 0; + border: none; + border-radius: var(--radius-box); + background: transparent; + max-width: none; + max-height: none; + } + + dialog.modal::backdrop { + background: rgba(11, 18, 32, 0.55); + } + + dialog.modal .modal-box { + background: #fff; + border: 1px solid var(--color-line); + border-radius: var(--radius-box); + padding: 1.5rem; + width: 32rem; + max-width: 91vw; + max-height: 85vh; + overflow-y: auto; + } + + /* No .modal-start variant here (unlike controlpanel.css) -- a native + shown via showModal() gets its own corner-rounding applied by Safari + regardless of author CSS (confirmed: zeroing border-radius on both the + itself and its .modal-box still left rounded corners), so a + right-side slide-in drawer uses the plain .drawer-backdrop/.drawer classes + below instead -- see signup_list.html. */ + + .modal-action { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1.5rem; + } + + .modal-backdrop { + display: none; + } + + /* A right-side slide-in drawer (signup_list.html) -- a plain fixed-position
+ toggled by JS (openDrawer/closeDrawer), not a native . Safari applies its + own corner-rounding to a shown via showModal() regardless of an author's + own border-radius: 0 (confirmed -- zeroing radius on both the itself and + its .modal-box still left rounded corners in Safari), so a plain div sidesteps that + native top-layer rendering entirely. transform/transition also gets a real slide + animation for free, which @starting-style-based entry animations can't + do reliably across browsers yet. */ + .drawer-backdrop { + display: none; + position: fixed; + inset: 0; + z-index: 50; + background: rgba(11, 18, 32, 0); + transition: background-color 0.2s ease; + } + + /* Split from .open on purpose -- display can't itself transition, so JS adds + .visible (display:block) and forces a reflow *before* adding .open (the + background/transform change), otherwise the browser never renders the + "just-appeared, still off-screen" frame to animate away from and the drawer + would just snap into place instead of sliding. */ + .drawer-backdrop.visible { + display: block; + } + + .drawer-backdrop.open { + background: rgba(11, 18, 32, 0.55); + } + + .drawer { + position: absolute; + top: 0; + right: 0; + height: 100%; + width: 420px; + max-width: 91vw; + display: flex; + flex-direction: column; + background: #fff; + border-left: 1px solid var(--color-line); + transform: translateX(100%); + transition: transform 0.2s ease; + } + + .drawer-backdrop.open .drawer { + transform: translateX(0); + } + + /* The club crest mark -- a circle fallback for a club with no logo uploaded (see + controlpanel.css's own .crest, which settled on a circle over the design's original + hexagon after it read as a "5-pointed" shape rather than a shield in practice). */ + .crest { + border-radius: 999px; + } + + /* "Place in" big team buttons on the Sign-up page (signup_list.html) -- a plain + CSS state class rather than juggling several Tailwind utilities via classList + in JS (see the page's own fetch handler), so toggling "placed" on click can't + drift out of sync with what the template renders server-side on a full reload. */ + .team-chip { + border-radius: 0.5rem; + padding: 0.5rem 0.875rem; + font-family: var(--font-display); + font-weight: 700; + font-size: 0.875rem; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid var(--color-line); + background: var(--color-paper); + color: var(--color-muted); + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease, + border-color 0.15s ease; + } + + .team-chip:hover:not(:disabled) { + border-color: var(--color-club); + color: var(--color-ink); + } + + .team-chip.placed, + .team-chip:disabled { + background: var(--color-ink); + color: #fff; + border-color: var(--color-ink); + cursor: default; + } + + /* Sidebar nav (management/templates/management/_nav_items.html). */ + .nav-item { + display: flex; + align-items: center; + gap: 0.625rem; + height: 2.5rem; + padding-inline: 0.75rem; + border-radius: 0.5rem; + font-family: var(--font-display); + font-weight: 700; + font-size: 1rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-sidebar-fg-dim); + } + + .nav-item:hover { + color: var(--color-sidebar-fg); + } + + .nav-item.active { + background: var(--color-club); + color: var(--color-club-content); + } + + .nav-subitem { + display: flex; + align-items: center; + gap: 0.5rem; + height: 1.875rem; + padding-left: 1.375rem; + font-size: 0.875rem; + color: var(--color-sidebar-fg-faint); + } + + .nav-subitem:hover, + .nav-subitem.active { + color: var(--color-sidebar-fg); + font-weight: 600; + } + + /* static/js/searchable-select.js's dropdown -- the script is shared with controlpanel.css + (see that file's own copy of this block) and hardcodes these daisyUI-era class names as + its public API rather than importing a stylesheet, so each app supplies its own visual + treatment under the same selectors instead of the script picking a look. */ + .bg-base-100 { + background: #fff; + } + + .menu { + list-style: none; + margin: 0; + padding: 0.375rem; + } + + .menu li a { + display: block; + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + font-family: var(--font-sans); + font-size: 0.875rem; + color: var(--color-ink); + cursor: pointer; + } + + .menu li a:hover { + background: var(--color-rule); + } + + .menu li a.menu-active { + background: var(--color-club); + color: #fff; + } +} diff --git a/authentication/middleware.py b/authentication/middleware.py index 8cbe9ac..fbb9f38 100644 --- a/authentication/middleware.py +++ b/authentication/middleware.py @@ -1,8 +1,8 @@ """Force MFA enrolment for privileged users. Anyone who can change other people's data must have a second factor: Django -staff/superusers, and anyone holding an elevated ``ClubRole`` (ADMIN or EDITOR) -in *any* club. Regular members may enrol, but aren't forced to. +staff/superusers, and anyone holding an elevated ``ClubRole`` (ADMIN, EDITOR, or +MEMBER_ADMIN) in *any* club. Regular members may enrol, but aren't forced to. Enrolled users are challenged for their second factor by allauth at login; this middleware only handles the other half — a privileged user who has never @@ -21,7 +21,7 @@ from club.models import ClubRole #: under DEBUG — without it, live reload dies on the enrolment page itself. EXEMPT_PREFIXES = ("/accounts/", "/static/", "/media/", "/__reload__/") -ELEVATED_ROLES = (ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR) +ELEVATED_ROLES = (ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR, ClubRole.Roles.MEMBER_ADMIN) def mfa_required_for(user) -> bool: diff --git a/billing/tasks.py b/billing/tasks.py new file mode 100644 index 0000000..6dfb21b --- /dev/null +++ b/billing/tasks.py @@ -0,0 +1,84 @@ +"""Celery tasks behind the billing beat schedule entries (see +rosterchief/settings.CELERY_BEAT_SCHEDULE and features/jobs.py). + +Each mirrors its management command's *acting* behaviour exactly -- manage.py's own +--dry-run/--commit flags exist for a human at a terminal to preview first (see +billing/management/commands/), which a beat schedule has no terminal to do. These always +act, the same as the crontab entries they replace always passed --commit. +""" + +from celery import shared_task +from django.utils import timezone + +from billing.services import BillingError +from billing.services.dues import archivable_clubs, renew, subscriptions_due_for_renewal +from billing.services.reminders import reminders_to_send, send_reminder +from club.models import Club +from features.models import Maintenance + + +def _stand_down(): + # Loud, not silent -- see events/tasks.py for why these raise instead of skipping quietly. + raise RuntimeError("Platform is in maintenance mode; this job stood down.") + + +@shared_task(name="billing.tasks.renew_subscriptions") +def renew_subscriptions(): + if Maintenance.is_on(): + _stand_down() + + due_for_renewal = subscriptions_due_for_renewal() + if not due_for_renewal: + return "Nothing to renew." + + renewed, failures = 0, [] + for subscription in due_for_renewal: + try: + renew(subscription) + renewed += 1 + except BillingError as error: + # One unpriced plan must not stop every other club from being billed. + failures.append(f"{subscription.club}: {error}") + + if failures: + raise RuntimeError(f"Renewed {renewed} club(s), {len(failures)} failed:\n " + "\n ".join(failures)) + + return f"Renewed {renewed} club(s)." + + +@shared_task(name="billing.tasks.send_billing_reminders") +def send_billing_reminders(): + if Maintenance.is_on(): + _stand_down() + + clubs = Club.objects.active().select_related("subscription", "subscription__plan").order_by("name") + sendable = [result for result in reminders_to_send(clubs) if result.sent] + + if not sendable: + return "Nothing owing. No reminders to send." + + sent, failures = 0, [] + for result in sendable: + try: + send_reminder(result.club, result.notice, recipients=result.recipients) + sent += 1 + except OSError as error: + # One bad address or a momentary SMTP failure must not stop the rest of the run. + failures.append(f"{result.club}: {error}") + + if failures: + raise RuntimeError(f"Sent {sent} reminder(s), {len(failures)} failed:\n " + "\n ".join(failures)) + + return f"Sent {sent} reminder(s)." + + +@shared_task(name="billing.tasks.archive_overdue_clubs") +def archive_overdue_clubs(): + if Maintenance.is_on(): + _stand_down() + + overdue = list(archivable_clubs(timezone.localdate())) + for due in overdue: + due.club.archive() + + return f"Archived {len(overdue)} club(s)." diff --git a/club/admin.py b/club/admin.py index 6579f44..24fb5c6 100644 --- a/club/admin.py +++ b/club/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.utils.translation import gettext_lazy as _ -from .models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor +from .models import Club, ClubMembership, ClubRole, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor @admin.register(Club) @@ -63,3 +63,19 @@ class ClubRoleAdmin(admin.ModelAdmin): search_fields = ["club__name", "member__last_name", "member__first_name"] list_filter = ["club", "role"] raw_id_fields = ["member"] + + +@admin.register(OnboardingRequirement) +class OnboardingRequirementAdmin(admin.ModelAdmin): + list_display = ["name", "club", "requires_document", "is_active", "order"] + list_filter = ["club", "is_active", "requires_document"] + search_fields = ["name", "club__name"] + ordering = ["club", "order", "name"] + + +@admin.register(MemberRequirementStatus) +class MemberRequirementStatusAdmin(admin.ModelAdmin): + list_display = ["membership", "requirement", "is_complete", "completed_at", "completed_by"] + list_filter = ["requirement__club", "is_complete", "requirement"] + search_fields = ["membership__member__last_name", "membership__member__first_name", "requirement__name"] + raw_id_fields = ["membership"] diff --git a/club/context_processors.py b/club/context_processors.py index 8d2f0bc..1a8a0a7 100644 --- a/club/context_processors.py +++ b/club/context_processors.py @@ -1,23 +1,46 @@ """Tenant-aware page branding. Every page inherits its chrome from ``base_template``. On a club subdomain that -resolves to the club-branded skin, on the base domain to the RosterChief one, so -the auth screens (login, password reset, MFA, passkeys — anything allauth ships, -now or later) follow the tenant without a single template of their own knowing -that clubs exist. +resolves to the club-branded skin, on the base domain to the platform one — the +control panel's own industrial design system (assets/controlpanel.css) — so the +auth screens (login, password reset, MFA, passkeys — anything allauth ships, now +or later) follow the tenant without a single template of their own knowing that +clubs exist. templates/403.html and templates/maintenance.html extend +``base_template`` directly too, so they follow the same split. -The control panel deliberately does *not* use this: it hardcodes the platform -base, so no branding bug can ever dress the platform panel up as a club. +A club subdomain serves two very different chromes, though: the public club site +(daisyUI, assets/app.css) and the management app (assets/management.css) live on +the same tenant, distinguished only by path. Without the checks below, a staff +member clicking "Change password" from inside the management app would land back +on the club's *public* skin -- jarring, and visually nothing like where they just +were. MANAGEMENT_BASE_TEMPLATE picks up management/base.html's own chrome instead, +for two cases: a request path directly under /manage/ (matching management/urls.py's +own hardcoded "manage/" prefix in rosterchief/urls.py -- e.g. a 403 on a management +page), and the session flag ClubStaffRequiredMixin.dispatch sets on every management +view (club/mixins.py) -- needed because allauth's password-change/MFA/logout screens +live under /accounts/, outside /manage/, so the path check alone can't see they were +reached from the management app's own user menu. + +The control panel's own pages deliberately do *not* use this: controlpanel/base.html +hardcodes itself, so no branding bug can ever dress the platform panel up as a club. """ -PLATFORM_BASE_TEMPLATE = "_platform_base.html" +PLATFORM_BASE_TEMPLATE = "controlpanel/_auth_base.html" CLUB_BASE_TEMPLATE = "_club_base.html" +MANAGEMENT_BASE_TEMPLATE = "management/_auth_base.html" def branding(request): club = getattr(request, "club", None) # set by ClubTenantMiddleware + if club and (request.path.startswith("/manage/") or request.session.get("management_context")): + base_template = MANAGEMENT_BASE_TEMPLATE + elif club: + base_template = CLUB_BASE_TEMPLATE + else: + base_template = PLATFORM_BASE_TEMPLATE + return { "club": club, - "base_template": CLUB_BASE_TEMPLATE if club else PLATFORM_BASE_TEMPLATE, + "base_template": base_template, } diff --git a/club/migrations/0025_onboardingrequirement_memberrequirementstatus_and_more.py b/club/migrations/0025_onboardingrequirement_memberrequirementstatus_and_more.py new file mode 100644 index 0000000..5c5dcae --- /dev/null +++ b/club/migrations/0025_onboardingrequirement_memberrequirementstatus_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 6.0.6 on 2026-08-16 20:42 + +import club.models +import django.core.files.storage +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('club', '0024_club_contact_email'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OnboardingRequirement', + fields=[ + ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, verbose_name='name')), + ('description', models.TextField(blank=True, help_text="Shown to staff on the member's checklist.", verbose_name='description')), + ('requires_document', models.BooleanField(default=False, help_text='Staff can attach a file (e.g. the certificate itself) when marking this complete.', verbose_name='requires a document')), + ('is_active', models.BooleanField(default=True, help_text='Inactive requirements no longer apply to new memberships, but existing statuses are kept.', verbose_name='active')), + ('order', models.PositiveIntegerField(default=0, help_text='Lower numbers show first on the checklist.', verbose_name='order')), + ('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')), + ], + options={ + 'verbose_name': 'onboarding requirement', + 'verbose_name_plural': 'onboarding requirements', + 'ordering': ['order', 'name'], + }, + ), + migrations.CreateModel( + name='MemberRequirementStatus', + fields=[ + ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_complete', models.BooleanField(default=False, verbose_name='complete')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='completed at')), + ('document', models.FileField(blank=True, help_text="Stored privately -- readable only through this member's own page, never a direct link.", storage=django.core.files.storage.FileSystemStorage(base_url=None, location='/Users/bernard/Code/PycharmProjects/RosterChief/private_media'), upload_to=club.models.onboarding_document_path, verbose_name='document')), + ('note', models.TextField(blank=True, help_text='Staff-only, e.g. how or when this was received.', verbose_name='note')), + ('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='completed by')), + ('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requirement_statuses', to='club.clubmembership', verbose_name='membership')), + ('requirement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='statuses', to='club.onboardingrequirement', verbose_name='requirement')), + ], + options={ + 'verbose_name': 'member requirement status', + 'verbose_name_plural': 'member requirement statuses', + }, + ), + migrations.AddConstraint( + model_name='onboardingrequirement', + constraint=models.UniqueConstraint(fields=('club', 'name'), name='unique_onboarding_requirement_name_per_club'), + ), + migrations.AddConstraint( + model_name='memberrequirementstatus', + constraint=models.UniqueConstraint(fields=('membership', 'requirement'), name='unique_requirement_status_per_membership'), + ), + ] diff --git a/club/migrations/0026_memberrequirementstatus_is_bypassed_and_more.py b/club/migrations/0026_memberrequirementstatus_is_bypassed_and_more.py new file mode 100644 index 0000000..d1ea102 --- /dev/null +++ b/club/migrations/0026_memberrequirementstatus_is_bypassed_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.6 on 2026-08-17 11:39 + +import club.models +import rosterchief.storage +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('club', '0025_onboardingrequirement_memberrequirementstatus_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='memberrequirementstatus', + name='is_bypassed', + field=models.BooleanField(default=False, verbose_name='bypassed'), + ), + migrations.AddField( + model_name='onboardingrequirement', + name='blocked_event_kinds', + field=models.JSONField(blank=True, default=list, help_text="Event kinds a member can't be invited to or selected for while this is open. Empty means purely informational.", verbose_name='blocks selection for'), + ), + migrations.AlterField( + model_name='clubrole', + name='role', + field=models.CharField(choices=[('admin', 'admin'), ('member', 'member'), ('editor', 'editor'), ('member_admin', 'member admin')], default='member', max_length=250, verbose_name='role'), + ), + migrations.AlterField( + model_name='memberrequirementstatus', + name='document', + field=models.FileField(blank=True, help_text="Stored privately -- readable only through this member's own page, never a direct link.", storage=rosterchief.storage.PrivateStorage(location='/Users/bernard/Code/PycharmProjects/RosterChief/private_media'), upload_to=club.models.onboarding_document_path, verbose_name='document'), + ), + ] diff --git a/club/mixins.py b/club/mixins.py index ce7b11b..d7444a0 100644 --- a/club/mixins.py +++ b/club/mixins.py @@ -4,7 +4,7 @@ from waffle import flag_is_active from members.models import Group -from .services.access import can_add_news, can_edit_news, can_publish_news, groups_manageable_by, has_management_access, is_club_admin, is_coach_manager, teams_managed_by +from .services.access import can_add_news, can_edit_news, can_manage_members, can_publish_news, groups_manageable_by, has_management_access, is_club_admin, is_coach_manager, teams_managed_by class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin): @@ -25,6 +25,12 @@ class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin): def dispatch(self, request, *args, **kwargs): if getattr(request, "club", None) is None: raise Http404("The management app is not available on the base domain.") + # Read by club/context_processors.py's branding() -- allauth's password-change/MFA/ + # logout screens live under /accounts/, not /manage/, so a path check alone can't + # tell they were reached from the management app's own user menu. This sticks for + # the rest of the session (nothing clears it back to False on a public-site visit), + # which is the right default for the common case of one person, one role. + request.session["management_context"] = True return super().dispatch(request, *args, **kwargs) def test_func(self): @@ -32,13 +38,28 @@ class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin): class ClubAdminRequiredMixin(ClubStaffRequiredMixin): - """ADMIN role only — club-wide settings that aren't scoped to a single team: - seasons, positions, roles, shop configuration.""" + """ADMIN role only (a platform superuser always passes too, see + is_club_admin) — genuinely admin-only ground: Finance/Shop, Club identity, + Sponsors, seasons, and granting/revoking ClubRole itself. Everything a + MEMBER_ADMIN may also touch uses MemberAdminRequiredMixin below instead.""" def test_func(self): return is_club_admin(self.request.user, self.request.club) +class MemberAdminRequiredMixin(ClubStaffRequiredMixin): + """ADMIN, a platform superuser, or MEMBER_ADMIN specifically -- full read/write + on people: members, families, groups, parent claims, member import, teams + (roster/staff/CRUD), referee levels, referee management, and onboarding + requirements. Deliberately does NOT cover Finance/Shop, Club identity, + Sponsors, or role-granting (role_list/role_create/role_revoke stay + ClubAdminRequiredMixin) -- a MEMBER_ADMIN must never be able to grant + themselves, or anyone else, real ADMIN.""" + + def test_func(self): + return can_manage_members(self.request.user, self.request.club) + + class FeatureRequiredMixin(ClubAdminRequiredMixin): """Gate for a whole management section (shop, forms, ...) this club doesn't have at all unless its waffle Flag (see the ``features`` app, set per-club diff --git a/club/models.py b/club/models.py index 2aed459..2e37b54 100644 --- a/club/models.py +++ b/club/models.py @@ -5,11 +5,13 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator from django.db import models +from django.db.models import Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ from members.models import Member from rosterchief.base import ClubScopedModel, UUIDModel, unique_slugify, validate_club_scope +from rosterchief.storage import private_storage class ClubManager(models.Manager): @@ -256,6 +258,14 @@ class Season(ClubScopedModel): context needed) -- the season that follows the one covering ``date``.""" return cls.objects.filter(club=club, start_date__gt=date).order_by("start_date").first() + @classmethod + def before(cls, club, season): + """Return ``club``'s most recent season starting before ``season`` -- + e.g. the management dashboard's member-count trend compares against + this. Mirrors next_after's own "adjacent by date" reasoning, just + looking the other way.""" + return cls.objects.filter(club=club, start_date__lt=season.start_date).order_by("-start_date").first() + class ClubMembership(ClubScopedModel): class Kind(models.TextChoices): @@ -319,6 +329,21 @@ class ClubMembership(ClubScopedModel): """ return self.kind == self.Kind.GUARDIAN + @property + def open_requirement_count(self) -> int: + """How many active onboarding requirements this membership hasn't resolved + yet (completed or bypassed) -- see OnboardingRequirement's docstring for why + this is separate from status/fee_status. One query per call; for a list of + memberships, annotate with club.services.onboarding.annotate_onboarding_status + instead.""" + met = set(self.requirement_statuses.filter(Q(is_complete=True) | Q(is_bypassed=True)).values_list("requirement_id", flat=True)) + required = set(OnboardingRequirement.objects.filter(club_id=self.club_id, is_active=True).values_list("pk", flat=True)) + return len(required - met) + + @property + def onboarding_complete(self) -> bool: + return self.open_requirement_count == 0 + def clean(self): validate_club_scope(self, self.club_id, same_club_fields=("season",)) # A guardian owes nothing -- they're not a member. Caught here rather than @@ -356,11 +381,104 @@ class FeePayment(UUIDModel): return f"{self.membership} — {self.amount}" +def onboarding_document_path(instance: MemberRequirementStatus, filename: str) -> str: + return f"clubs/{instance.membership.club.slug}/onboarding/{instance.membership_id}/{filename}" + + +class OnboardingRequirement(ClubScopedModel): + """A club-defined item every member must satisfy after signing up or renewing -- + e.g. "provide a medical certificate", "upload a photo". + + ``ClubMembership.fee_status`` is still driven by payment alone (see + ``club.services.fees._sync_fee_status``) and this never touches it -- a member + reads as paid *and* still has an open checklist, both true at once. ``status`` + is different: paying in full only ever settles ``fee_status`` now -- it never + flips ``status`` to ACTIVE by itself. The only path there is the deliberately + manual one, ``club.services.onboarding.approve_one``/``approve_all_clean``, run + by an admin from the Sign-up page, which additionally requires every blocking + requirement to be resolved first. Nothing flips status automatically just + because the fee cleared or the last checklist item was ticked (checklist actions + aren't even admin-gated); activation is always that one deliberate admin step, + so a membership can be fully paid *and* fully checked off and still sit PENDING + until someone actually clicks Approve. + + ``blocked_event_kinds`` is what makes a specific requirement matter before that + point: a club can decide e.g. a medical certificate blocks GAME invitations/ + selection but not TRAINING ones, so a provisionally-rostered member (see + ``events.services.attendance.effective_members``) can still be invited to practice + while their paperwork is outstanding. Empty means "informational only" -- open or + not, it never blocks anything. Stored as a plain list of ``events.models.Event. + EventKind`` values (not a FK/enum at the DB layer) specifically to avoid a + club -> events import cycle (events already imports club for Event.club); the + form layer (management/forms.py) is what actually validates against EventKind. + + ``MemberRequirementStatus`` tracks completion per ``ClubMembership`` (so a fresh + checklist starts each season, matching how membership itself is season-scoped). + """ + + name = models.CharField(_("name"), max_length=100) + description = models.TextField(_("description"), blank=True, help_text=_("Shown to staff on the member's checklist.")) + requires_document = models.BooleanField(_("requires a document"), default=False, help_text=_("Staff can attach a file (e.g. the certificate itself) when marking this complete.")) + blocked_event_kinds = models.JSONField(_("blocks selection for"), default=list, blank=True, help_text=_("Event kinds a member can't be invited to or selected for while this is open. Empty means purely informational.")) + is_active = models.BooleanField(_("active"), default=True, help_text=_("Inactive requirements no longer apply to new memberships, but existing statuses are kept.")) + order = models.PositiveIntegerField(_("order"), default=0, help_text=_("Lower numbers show first on the checklist.")) + + class Meta: + verbose_name = _("onboarding requirement") + verbose_name_plural = _("onboarding requirements") + ordering = ["order", "name"] + constraints = [ + models.UniqueConstraint(fields=["club", "name"], name="unique_onboarding_requirement_name_per_club"), + ] + + def __str__(self): + return self.name + + +class MemberRequirementStatus(UUIDModel): + """Whether one ``ClubMembership`` has satisfied one ``OnboardingRequirement``, + this season. Not itself club-scoped -- its club is reached through ``membership``, + same reasoning as ``FeePayment`` above.""" + + membership = models.ForeignKey(ClubMembership, on_delete=models.CASCADE, related_name="requirement_statuses", verbose_name=_("membership")) + requirement = models.ForeignKey(OnboardingRequirement, on_delete=models.CASCADE, related_name="statuses", verbose_name=_("requirement")) + is_complete = models.BooleanField(_("complete"), default=False) + #: Distinct from is_complete -- "confirmed, not needed for this person" (e.g. they + #: already have a recent photo on file) reads differently from "actually received" + #: on a checklist/audit, even though both equally stop this item from blocking + #: anything (see club.services.onboarding.is_open). Mutually exclusive with + #: is_complete in practice (mark_bypassed/mark_complete each clear the other). + is_bypassed = models.BooleanField(_("bypassed"), default=False) + completed_at = models.DateTimeField(_("completed at"), null=True, blank=True) + completed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+", verbose_name=_("completed by")) + document = models.FileField(_("document"), storage=private_storage, upload_to=onboarding_document_path, blank=True, help_text=_("Stored privately -- readable only through this member's own page, never a direct link.")) + note = models.TextField(_("note"), blank=True, help_text=_("Staff-only, e.g. how or when this was received.")) + + class Meta: + verbose_name = _("member requirement status") + verbose_name_plural = _("member requirement statuses") + constraints = [ + models.UniqueConstraint(fields=["membership", "requirement"], name="unique_requirement_status_per_membership"), + ] + + def __str__(self): + return f"{self.membership} — {self.requirement}" + + def clean(self): + validate_club_scope(self, self.membership.club_id, same_club_fields=("requirement",)) + + class ClubRole(ClubScopedModel): class Roles(models.TextChoices): ADMIN = "admin", _("admin") MEMBER = "member", _("member") EDITOR = "editor", _("editor") + #: Full read/write on people (members, families, groups, parent claims, + #: teams, referee setup, onboarding requirements) without Finance/Shop, + #: Club identity, Sponsors, or the ability to grant/revoke ClubRole itself + #: -- see club.services.access.can_manage_members and + #: club.mixins.MemberAdminRequiredMixin for exactly what that covers. + MEMBER_ADMIN = "member_admin", _("member admin") member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="roles", verbose_name=_("member")) role = models.CharField(_("role"), max_length=250, choices=Roles.choices, default=Roles.MEMBER) diff --git a/club/services/access.py b/club/services/access.py index 95faa68..d8f1394 100644 --- a/club/services/access.py +++ b/club/services/access.py @@ -45,19 +45,44 @@ def has_club_role(user: User, club: Club, role: ClubRole.Roles) -> bool: return ClubRole.objects.filter(member__user=user, club=club, role=role).exists() +def is_platform_superuser(user: User) -> bool: + """A Django superuser sees and manages every club as if they held ADMIN there, + with no ClubRole row needed -- the platform-operator override. Already forced + through MFA regardless (authentication.middleware.mfa_required_for checks + is_superuser directly), so this bypass never skips that.""" + return bool(user and user.is_authenticated and user.is_superuser) + + def is_club_admin(user: User, club: Club) -> bool: - return has_club_role(user, club, ClubRole.Roles.ADMIN) + return is_platform_superuser(user) or has_club_role(user, club, ClubRole.Roles.ADMIN) + + +def is_member_admin(user: User, club: Club) -> bool: + """MEMBER_ADMIN: full read/write on people (members, families, groups, parent + claims, teams, referee setup, onboarding requirements) without Finance/Shop, + Club identity, Sponsors, or the ability to grant/revoke ClubRole itself -- + see can_manage_members for the actual gate, this is just the role check.""" + return has_club_role(user, club, ClubRole.Roles.MEMBER_ADMIN) + + +def can_manage_members(user: User, club: Club) -> bool: + """The gate for club.mixins.MemberAdminRequiredMixin -- real ADMIN (which already + includes the superuser bypass), or MEMBER_ADMIN specifically.""" + return is_club_admin(user, club) or is_member_admin(user, club) def has_management_access(user: User, club: Club) -> bool: - """Anyone with real authority in the club: ADMIN/EDITOR, or *any* current-season - staff assignment (coach, team manager, physio, ...). + """Anyone with real authority in the club: ADMIN/EDITOR/MEMBER_ADMIN, a platform + superuser, or *any* current-season staff assignment (coach, team manager, + physio, ...). Deliberately excludes the plain MEMBER role -- every signed-up player (or club member generally) holds that automatically the moment their ClubMembership goes active (club/signals.py), so it says nothing about whether someone is staff. """ - elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)).exists() + if is_platform_superuser(user): + return True + elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR, ClubRole.Roles.MEMBER_ADMIN)).exists() return elevated or teams_staffed_by(user, club).exists() diff --git a/club/services/fees.py b/club/services/fees.py index 5974410..758a942 100644 --- a/club/services/fees.py +++ b/club/services/fees.py @@ -8,7 +8,6 @@ step here, never recomputed by re-aggregating FeePayment on every read. from decimal import Decimal from django.db.models import F -from django.utils import timezone from club.models import ClubMembership, FeePayment @@ -20,7 +19,8 @@ def remaining_balance(membership): def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None): """Record money received against one membership's fee. Several payments may land on one membership -- a family paying in two installments must not read as - unpaid. Updates amount_paid and re-syncs fee_status/status to match.""" + unpaid. Updates amount_paid and re-syncs fee_status to match; membership.status + is untouched -- see _sync_fee_status.""" payment = FeePayment.objects.create(membership=membership, amount=amount, method=method, reference=reference, note=note, recorded_by=recorded_by) membership.amount_paid = F("amount_paid") + amount @@ -54,16 +54,10 @@ def _sync_fee_status(membership, *, force_paid=False): else: new_status = ClubMembership.FeeStatus.UNPAID + # fee_status only -- membership.status is never touched here. Paying in full + # used to also flip status straight to ACTIVE on its own; now that's exclusively + # club.services.onboarding.approve_one/approve_all_clean's call, so a paid-up + # membership still waits on that deliberate admin step. See OnboardingRequirement's + # docstring (club/models.py) for why. membership.fee_status = new_status - update_fields = ["fee_status"] - - # Same "become a full member" behavior the bulk action already had: settling - # the fee in full also activates the membership, once, first time only. - if new_status == ClubMembership.FeeStatus.PAID: - membership.status = ClubMembership.StatusChoices.ACTIVE - update_fields.append("status") - if membership.activated_at is None: - membership.activated_at = timezone.localdate() - update_fields.append("activated_at") - - membership.save(update_fields=update_fields) + membership.save(update_fields=["fee_status"]) diff --git a/club/services/onboarding.py b/club/services/onboarding.py new file mode 100644 index 0000000..891386f --- /dev/null +++ b/club/services/onboarding.py @@ -0,0 +1,231 @@ +"""Per-member onboarding checklist -- see OnboardingRequirement's docstring +(club/models.py) for why fee_status stays untouched by any of this, and for +why approve_one/approve_all_clean below are the only way to reach +ClubMembership.status ACTIVE (fee_status alone, even fully PAID, never does). + +No signal pre-creates a MemberRequirementStatus row when a membership is created +or a requirement is added: "required, no row yet" and "required, row with +is_complete=is_bypassed=False" both mean the same thing (not done), so there is +nothing to backfill either way -- a club adding a new requirement mid-season +immediately shows it as open on every existing membership, and deactivating one +immediately stops asking for it, with no migration-shaped cleanup step in either +direction. +""" + +from collections import defaultdict + +from django.db.models import Q +from django.utils import timezone + +from club.models import ClubMembership, MemberRequirementStatus, OnboardingRequirement +from members.models import Member + +#: Shared by every "is this item resolved" check below -- resolved means it no +#: longer blocks anything, whether that's because it was actually completed or +#: because staff decided it doesn't apply to this person. +_RESOLVED = Q(is_complete=True) | Q(is_bypassed=True) + + +def checklist_for(membership): + """Every active requirement for this membership's club, each paired with its + status row if one exists (or None -- not started). One query for the + requirements, one for the statuses that exist; the membership detail page + renders exactly this list under its Documents tab.""" + requirements = OnboardingRequirement.objects.filter(club_id=membership.club_id, is_active=True) + statuses = {status.requirement_id: status for status in membership.requirement_statuses.select_related("completed_by")} + + return [(requirement, statuses.get(requirement.pk)) for requirement in requirements] + + +def mark_complete(membership, requirement, *, user, document=None, note=""): + """Actually received/verified -- as opposed to mark_bypassed, "not needed for + this person". Clears any prior bypass: the two are mutually exclusive.""" + status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement) + status.is_complete = True + status.is_bypassed = False + status.completed_at = timezone.now() + status.completed_by = user + status.note = note + if document: + status.document = document + status.save() + + return status + + +def mark_bypassed(membership, requirement, *, user, note=""): + """Confirmed not needed for this member (e.g. they already have a recent + photo on file) -- stops the item blocking anything, same as mark_complete, + but reads correctly on the checklist/audit trail as a deliberate staff + decision rather than a document actually received. A note is expected here + (not enforced at this layer -- see RequirementBypassForm) since "why" is the + whole point of a bypass in a way it isn't for an ordinary completion.""" + status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement) + status.is_complete = False + status.is_bypassed = True + status.completed_at = timezone.now() + status.completed_by = user + status.note = note + status.document = None + status.save() + + return status + + +def mark_incomplete(membership, requirement): + """Undo a mark_complete/mark_bypassed -- kept as a row (not deleted) so the + document/note a club already collected isn't thrown away by an accidental + toggle.""" + status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement) + status.is_complete = False + status.is_bypassed = False + status.completed_at = None + status.completed_by = None + status.save() + + return status + + +def annotate_onboarding_status(queryset): + """`queryset` of ClubMembership, returned as a list with each row given an + `.onboarding_open` attribute (count of unresolved active requirements) -- the + list-page equivalent of the `open_requirement_count` property, in a fixed + number of queries regardless of list size rather than the N+1 a per-row + property call would cost across a whole table.""" + memberships = list(queryset) + if not memberships: + return memberships + + required_by_club = {} + for club_id in {membership.club_id for membership in memberships}: + required_by_club[club_id] = set(OnboardingRequirement.objects.filter(club_id=club_id, is_active=True).values_list("pk", flat=True)) + + met_by_membership = defaultdict(set) + statuses = MemberRequirementStatus.objects.filter(membership_id__in=[membership.pk for membership in memberships]).filter(_RESOLVED) + for membership_id, requirement_id in statuses.values_list("membership_id", "requirement_id"): + met_by_membership[membership_id].add(requirement_id) + + for membership in memberships: + required = required_by_club.get(membership.club_id, set()) + membership.onboarding_open = len(required - met_by_membership[membership.pk]) + + return memberships + + +def members_with_open_requirements(club, season): + """Members whose current-season membership has at least one unresolved active + requirement -- the same condition the dashboard's "Missing documentation" KPI + counts (management.views.HomeView), reused here for the member list's own + ?docs=open filter. None when there's no season to check against.""" + if season is None: + return Member.objects.none() + + memberships = list(ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER)) + annotate_onboarding_status(memberships) + member_ids = [membership.member_id for membership in memberships if membership.onboarding_open] + return Member.objects.filter(pk__in=member_ids) + + +def blocking_event_kinds(membership) -> set: + """Every event kind currently blocked for this membership by at least one open + (not complete, not bypassed) active requirement -- e.g. {"game"} while a medical + certificate is outstanding but nothing blocks training. Powers the Sign-up page's + detail pane and member_detail's Documents tab ("blocks: Games" next to an open + item), so staff can see exactly what's at stake without reading every requirement.""" + blocked = set() + for requirement, status in checklist_for(membership): + if status is not None and (status.is_complete or status.is_bypassed): + continue + blocked.update(requirement.blocked_event_kinds) + return blocked + + +def blocked_member_ids_for_event(club, season, event_kind) -> set: + """Member ids that must NOT be invited to (or selectable for) an event of + `event_kind` this season, because at least one active requirement that blocks + that kind is still open on their current-season membership. Bulk, not per-member + -- events.services.attendance.effective_members() calls this once per event save, + not once per candidate member. + + A member with no current-season ClubMembership.MEMBER row at all isn't covered + here -- effective_members() already wouldn't include them (they're not on any + roster to begin with), so there's nothing to subtract. + + Filtered in Python, not via a `blocked_event_kinds__contains=[event_kind]` + queryset lookup -- JSONField `contains` isn't supported on SQLite (only + Postgres/MySQL/Oracle), and a club's own requirement count is always small + enough that fetching them all costs nothing worth optimising away.""" + blocking_requirement_ids = {requirement.pk for requirement in OnboardingRequirement.objects.filter(club=club, is_active=True) if event_kind in requirement.blocked_event_kinds} + if not blocking_requirement_ids: + return set() + + memberships = ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER) + resolved_by_membership = defaultdict(set) + statuses = MemberRequirementStatus.objects.filter(membership__in=memberships, requirement_id__in=blocking_requirement_ids).filter(_RESOLVED) + for membership_id, requirement_id in statuses.values_list("membership_id", "requirement_id"): + resolved_by_membership[membership_id].add(requirement_id) + + blocked_member_ids = set() + for membership_id, member_id in memberships.values_list("pk", "member_id"): + if blocking_requirement_ids - resolved_by_membership.get(membership_id, set()): + blocked_member_ids.add(member_id) + return blocked_member_ids + + +#: Fee states "clean" enough to activate on -- PARTIALLY_PAID/UNPAID never are. +_CLEAN_FEE_STATUSES = (ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED) + + +def is_signup_clean(membership) -> bool: + """Paid up (or waived) and every active requirement resolved -- what both + approve_all_clean and approve_one gate on, and what the Sign-up page's + per-member Approve button enables/disables against. Not itself a shortcut + for "already active": a membership can be exactly this clean and still be + PENDING, waiting on this deliberately manual step.""" + return membership.fee_status in _CLEAN_FEE_STATUSES and membership.onboarding_complete + + +def approve_one(membership) -> bool: + """Admin-triggered single activation from the Sign-up page's detail panel -- + same rule and same reasoning as approve_all_clean, just one membership instead + of a whole season's queue. Returns whether it actually activated (False if it + wasn't PENDING or wasn't clean).""" + if membership.status != ClubMembership.StatusChoices.PENDING or not is_signup_clean(membership): + return False + membership.status = ClubMembership.StatusChoices.ACTIVE + update_fields = ["status"] + if membership.activated_at is None: + membership.activated_at = timezone.localdate() + update_fields.append("activated_at") + membership.save(update_fields=update_fields) + return True + + +def approve_all_clean(club, season) -> int: + """Admin-triggered bulk activation from the Sign-up page -- the *only* path to + ClubMembership.status ACTIVE (see OnboardingRequirement's docstring: paying in + full only settles fee_status now, club.services.fees._sync_fee_status never + touches status). Only ever moves PENDING -> ACTIVE, and only for a membership + that is both paid up (fee_status PAID or WAIVED) and has resolved every active + requirement -- "manual documentation check to be done by the admin" means + clicking this once everything has actually been checked, not something that runs + on its own. Returns how many memberships were activated.""" + memberships = list( + ClubMembership.objects.filter( + club=club, + season=season, + kind=ClubMembership.Kind.MEMBER, + status=ClubMembership.StatusChoices.PENDING, + fee_status__in=_CLEAN_FEE_STATUSES, + ) + ) + annotate_onboarding_status(memberships) + ready = [membership for membership in memberships if membership.onboarding_open == 0] + today = timezone.localdate() + for membership in ready: + membership.status = ClubMembership.StatusChoices.ACTIVE + if membership.activated_at is None: + membership.activated_at = today + if ready: + ClubMembership.objects.bulk_update(ready, ["status", "activated_at"]) + return len(ready) diff --git a/club/tasks.py b/club/tasks.py new file mode 100644 index 0000000..bbde939 --- /dev/null +++ b/club/tasks.py @@ -0,0 +1,34 @@ +"""Celery task behind the `generate-seasons` beat schedule entry (see +rosterchief/settings.CELERY_BEAT_SCHEDULE and features/jobs.py). + +Mirrors `manage.py generate_seasons`'s default behaviour (generate, not --resync) exactly -- +that command still exists, unchanged, for manual use from a shell, including --resync, which +this task deliberately does not run unattended (see club/management/commands/generate_seasons.py: +--resync can delete rows, so it isn't something a beat schedule should do on its own). +""" + +from celery import shared_task +from dateutil.relativedelta import relativedelta +from django.utils import timezone + +from club.models import Club +from club.services.seasons import generate_seasons as generate_seasons_for_club +from features.models import Maintenance + +#: How far ahead to generate, matching the management command's own default. +YEARS_AHEAD = 2 + + +@shared_task(name="club.tasks.generate_seasons") +def generate_seasons(): + if Maintenance.is_on(): + raise RuntimeError("Platform is in maintenance mode; this job stood down.") + + until = timezone.localdate() + relativedelta(years=YEARS_AHEAD) + clubs = Club.objects.active() + + total = 0 + for club in clubs: + total += len(generate_seasons_for_club(club, until)) + + return f"Generated {total} season(s) across {clubs.count()} club(s)." diff --git a/club/tests.py b/club/tests.py index b4ce02c..a72b94b 100644 --- a/club/tests.py +++ b/club/tests.py @@ -8,6 +8,7 @@ from allauth.mfa.models import Authenticator from dateutil.relativedelta import relativedelta from django.contrib import admin as django_admin from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ValidationError from django.core.management import call_command from django.db import IntegrityError @@ -21,18 +22,34 @@ from members.models import Family, FamilyMembership, Member from teams.models import Position, StaffAssignment, Team, TeamMembership from teams.services import eligible_roster_members -from .models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor, club_logo_path +from .models import Club, ClubMembership, ClubRole, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor, club_logo_path from .services.access import ( COACH_MANAGER, can_edit_event, + can_manage_members, can_manage_shop, has_club_role, + has_management_access, + is_club_admin, + is_member_admin, + is_platform_superuser, members_visible_to, roles_in_club, teams_managed_by, teams_staffed_by, ) from .services.fees import mark_as_paid, record_payment, remaining_balance +from .services.onboarding import ( + annotate_onboarding_status, + approve_all_clean, + approve_one, + blocked_member_ids_for_event, + blocking_event_kinds, + checklist_for, + mark_bypassed, + mark_complete, + mark_incomplete, +) from .services.seasons import _initial_season_start, _season_end, generate_seasons, resync_seasons from .tenancy import ( ClubTenantMiddleware, @@ -496,6 +513,26 @@ class SeasonNextAfterTests(TestCase): self.assertEqual(Season.next_after(other, datetime.date(2026, 12, 25)).club, other) +class SeasonBeforeTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.previous = Season.objects.create(club=cls.club, start_date=datetime.date(2025, 8, 1), end_date=datetime.date(2026, 5, 31)) + cls.current = Season.objects.create(club=cls.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31)) + + def test_returns_the_most_recent_season_starting_before_this_one(self): + self.assertEqual(Season.before(self.club, self.current), self.previous) + + def test_returns_none_when_there_is_no_earlier_season(self): + self.assertIsNone(Season.before(self.club, self.previous)) + + def test_is_scoped_to_the_given_club(self): + other = Club.objects.create(name="Rival FC", slug="rival-fc") + other_current = Season.objects.create(club=other, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31)) + + self.assertIsNone(Season.before(other, other_current)) + + class SponsorModelTests(TestCase): @classmethod def setUpTestData(cls): @@ -1035,6 +1072,61 @@ class AccessServiceTests(TestCase): self.assertTrue(can_manage_shop(admin_user, self.club)) self.assertFalse(can_manage_shop(editor_user, self.club)) + # --- platform superuser bypass --- + def test_superuser_is_club_admin_everywhere_with_no_clubrole_at_all(self): + user, _ = self.make_user_member("root@example.com") + user.is_superuser = True + user.save() + + self.assertTrue(is_club_admin(user, self.club)) + self.assertTrue(is_club_admin(user, self.other_club)) + self.assertTrue(has_management_access(user, self.club)) + self.assertTrue(is_platform_superuser(user)) + + def test_a_plain_staff_flag_alone_is_not_the_superuser_bypass(self): + user, _ = self.make_user_member("staffonly@example.com") + user.is_staff = True + user.save() + + self.assertFalse(is_club_admin(user, self.club)) + self.assertFalse(is_platform_superuser(user)) + + def test_an_anonymous_user_is_never_the_superuser_bypass(self): + self.assertFalse(is_platform_superuser(AnonymousUser())) + + # --- MEMBER_ADMIN / can_manage_members --- + def test_member_admin_role_grants_can_manage_members_but_not_is_club_admin(self): + user, member = self.make_user_member("memberadmin@example.com") + self.grant(member, ClubRole.Roles.MEMBER_ADMIN) + + self.assertTrue(is_member_admin(user, self.club)) + self.assertTrue(can_manage_members(user, self.club)) + self.assertFalse(is_club_admin(user, self.club)) + + def test_real_admin_also_satisfies_can_manage_members(self): + user, member = self.make_user_member("admin@example.com") + self.grant(member, ClubRole.Roles.ADMIN) + + self.assertTrue(can_manage_members(user, self.club)) + + def test_editor_alone_does_not_satisfy_can_manage_members(self): + user, member = self.make_user_member("editor@example.com") + self.grant(member, ClubRole.Roles.EDITOR) + + self.assertFalse(can_manage_members(user, self.club)) + + def test_member_admin_counts_as_management_access(self): + user, member = self.make_user_member("memberadmin@example.com") + self.grant(member, ClubRole.Roles.MEMBER_ADMIN) + + self.assertTrue(has_management_access(user, self.club)) + + def test_member_admin_in_one_club_has_no_bearing_on_another(self): + user, member = self.make_user_member("memberadmin@example.com") + ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.MEMBER_ADMIN) + + self.assertFalse(can_manage_members(user, self.other_club)) + class ClubRoleStatusSyncTests(TestCase): @classmethod @@ -1154,16 +1246,16 @@ class BrandingTests(TestCase): def test_the_base_domain_gets_the_platform_skin(self): response = self.login_page("rosterchief.app") - self.assertTemplateUsed(response, "_platform_base.html") + self.assertTemplateUsed(response, "controlpanel/_auth_base.html") self.assertTemplateNotUsed(response, "_club_base.html") - self.assertContains(response, "Club & Team Management") + self.assertContains(response, "RosterChief") self.assertIsNone(response.context["club"]) def test_a_club_subdomain_gets_the_club_skin(self): response = self.login_page("ajax-united.rosterchief.app") self.assertTemplateUsed(response, "_club_base.html") - self.assertTemplateNotUsed(response, "_platform_base.html") + self.assertTemplateNotUsed(response, "controlpanel/_auth_base.html") self.assertContains(response, "Ajax United") self.assertEqual(response.context["club"], self.club) @@ -1171,7 +1263,7 @@ class BrandingTests(TestCase): # The subdomain stops resolving, so there is no club to brand with. self.club.archive() - self.assertTemplateUsed(self.login_page("ajax-united.rosterchief.app"), "_platform_base.html") + self.assertTemplateUsed(self.login_page("ajax-united.rosterchief.app"), "controlpanel/_auth_base.html") def test_a_club_without_a_logo_shows_its_initials_not_our_mark(self): response = self.login_page("ajax-united.rosterchief.app") @@ -1210,6 +1302,55 @@ class BrandingTests(TestCase): self.assertNotContains(self.login_page("ajax-united.rosterchief.app"), "--color-secondary") +@override_settings( + ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", + ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], +) +class ManagementBrandingTests(TestCase): + """allauth's password-change/MFA/logout screens live under /accounts/, outside + /manage/, so branding() (this module) can't tell they were reached from the + management app's own user menu by path alone -- it also checks the session flag + ClubStaffRequiredMixin.dispatch sets (club/mixins.py). These are the tests for + that flag, as distinct from BrandingTests above (which only covers the plain + per-tenant split, never touching /manage/ at all).""" + + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.season = Season.objects.create(club=cls.club, start_date=timezone.localdate() - datetime.timedelta(days=30), end_date=timezone.localdate() + datetime.timedelta(days=300)) + + cls.staff_user = get_user_model().objects.create_user(email="staff@example.com", password="pw-secret-123") + member = Member.objects.create(user=cls.staff_user, first_name="Ada", last_name="Admin") + ClubMembership.objects.create(club=cls.club, member=member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) + ClubRole.objects.filter(club=cls.club, member=member).update(role=ClubRole.Roles.ADMIN) + Authenticator.objects.create(user=cls.staff_user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) + + def test_the_change_password_screen_stays_club_branded_without_a_visit_to_manage(self): + self.client.force_login(self.staff_user) + + response = self.client.get(reverse("account_change_password"), HTTP_HOST="ajax-united.rosterchief.app") + + self.assertTemplateUsed(response, "_club_base.html") + self.assertTemplateNotUsed(response, "management/_auth_base.html") + + def test_the_change_password_screen_gets_the_management_skin_after_visiting_manage(self): + self.client.force_login(self.staff_user) + self.client.get(reverse("management:home"), HTTP_HOST="ajax-united.rosterchief.app") + + response = self.client.get(reverse("account_change_password"), HTTP_HOST="ajax-united.rosterchief.app") + + self.assertTemplateUsed(response, "management/_auth_base.html") + self.assertContains(response, "Ajax United") + + def test_the_mfa_index_screen_gets_the_management_skin_after_visiting_manage(self): + self.client.force_login(self.staff_user) + self.client.get(reverse("management:home"), HTTP_HOST="ajax-united.rosterchief.app") + + response = self.client.get(reverse("mfa_index"), HTTP_HOST="ajax-united.rosterchief.app") + + self.assertTemplateUsed(response, "management/_auth_base.html") + + @override_settings( ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], @@ -1217,14 +1358,17 @@ class BrandingTests(TestCase): class Custom403PageTests(TestCase): """Django's default 403 handler picks up templates/403.html automatically -- branded per tenant (base_template, same as maintenance.html) so a permission - error still looks like the app, not a bare Django error page, and the navbar - (sign out, theme toggle, home link) stays reachable.""" + error still looks like the app, not a bare Django error page. A club subdomain + itself splits further: a /manage/ URL gets the management app's own skin + (management/_auth_base.html) rather than the club's public one, matching every + other allauth-adjacent screen reached from inside the management app -- see + club/context_processors.py's MANAGEMENT_BASE_TEMPLATE.""" @classmethod def setUpTestData(cls): cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") - def test_a_club_subdomain_403_gets_the_club_skin(self): + def test_a_manage_url_403_gets_the_management_skin(self): member = get_user_model().objects.create_user(email="member-403@example.com", password="pw-secret-123") self.client.force_login(member) @@ -1233,7 +1377,7 @@ class Custom403PageTests(TestCase): self.assertEqual(response.status_code, 403) self.assertContains(response, "Access denied", status_code=403) self.assertContains(response, "Ajax United", status_code=403) - self.assertContains(response, "Sign out", status_code=403) + self.assertTemplateUsed(response, "management/_auth_base.html") def test_the_base_domain_403_gets_the_platform_skin(self): self.client.force_login(get_user_model().objects.create_user(email="platform-403@example.com", password="pw-secret-123")) @@ -1242,7 +1386,8 @@ class Custom403PageTests(TestCase): self.assertEqual(response.status_code, 403) self.assertContains(response, "Access denied", status_code=403) - self.assertContains(response, "Club & Team Management", status_code=403) + self.assertTemplateUsed(response, "controlpanel/_auth_base.html") + self.assertContains(response, "RosterChief", status_code=403) class ClubBrandingModelTests(TestCase): @@ -1352,17 +1497,21 @@ class FeeServiceTests(TestCase): self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID) self.assertEqual(FeePayment.objects.filter(membership=self.membership).count(), 2) - def test_reaching_the_full_amount_settles_and_activates(self): + def test_reaching_the_full_amount_settles_the_fee_but_leaves_status_pending(self): + # Paying in full only ever settles fee_status now -- activation is + # exclusively club.services.onboarding.approve_one/approve_all_clean's call + # (see OnboardingRequirement's docstring), so a membership can be fully paid + # and still sit PENDING until an admin actually approves it. record_payment(self.membership, amount=Decimal("100.00")) record_payment(self.membership, amount=Decimal("50.00")) self.membership.refresh_from_db() self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) - self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE) - self.assertEqual(self.membership.activated_at, timezone.localdate()) - self.assertTrue(self.roles().filter(role=ClubRole.Roles.MEMBER).exists()) + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING) + self.assertIsNone(self.membership.activated_at) + self.assertFalse(self.roles().filter(role=ClubRole.Roles.MEMBER).exists()) - def test_settling_in_full_does_not_overwrite_an_earlier_activated_at(self): + def test_settling_in_full_never_touches_activated_at(self): earlier = datetime.date(2026, 1, 1) self.membership.activated_at = earlier self.membership.save() @@ -1400,7 +1549,7 @@ class FeeServiceTests(TestCase): unpriced.refresh_from_db() self.assertEqual(unpriced.fee_status, ClubMembership.FeeStatus.PAID) - self.assertEqual(unpriced.status, ClubMembership.StatusChoices.ACTIVE) + self.assertEqual(unpriced.status, ClubMembership.StatusChoices.PENDING) self.assertFalse(FeePayment.objects.filter(membership=unpriced).exists()) def test_recorded_by_is_stored_on_the_payment(self): @@ -1652,3 +1801,294 @@ class GenerateSeasonsCommandTests(TestCase): self.assertFalse(Season.objects.filter(pk=wrong.pk).exists()) + + +class OnboardingRequirementTests(TestCase): + """club.services.onboarding -- deliberately orthogonal to status/fee_status (see + OnboardingRequirement's docstring): a fully paid, active membership can still + have open requirements, and neither field moves when one is marked complete.""" + + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.season = make_season(cls.club) + cls.member = Member.objects.create(first_name="Jane", last_name="Doe") + cls.membership = ClubMembership.objects.create( + club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.PAID + ) + cls.staff = get_user_model().objects.create_user(email="staff@example.com", password="pw-secret-123") + cls.photo = OnboardingRequirement.objects.create(club=cls.club, name="Photo", order=1) + cls.medical = OnboardingRequirement.objects.create(club=cls.club, name="Medical certificate", requires_document=True, order=2) + + def test_a_membership_with_no_status_rows_has_every_requirement_open(self): + self.assertEqual(self.membership.open_requirement_count, 2) + self.assertFalse(self.membership.onboarding_complete) + + def test_marking_one_complete_leaves_the_other_open(self): + mark_complete(self.membership, self.photo, user=self.staff) + + self.assertEqual(self.membership.open_requirement_count, 1) + self.assertFalse(self.membership.onboarding_complete) + + def test_completing_every_requirement_clears_the_membership(self): + mark_complete(self.membership, self.photo, user=self.staff) + mark_complete(self.membership, self.medical, user=self.staff) + + self.assertTrue(self.membership.onboarding_complete) + + def test_marking_complete_never_touches_status_or_fee_status(self): + # The whole point: a document upload must never re-derive membership state -- + # club.services.fees owns status/fee_status exclusively. + unpaid = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING) + + mark_complete(unpaid, self.photo, user=self.staff) + mark_complete(unpaid, self.medical, user=self.staff) + unpaid.refresh_from_db() + + self.assertTrue(unpaid.onboarding_complete) + self.assertEqual(unpaid.status, ClubMembership.StatusChoices.PENDING) + self.assertEqual(unpaid.fee_status, ClubMembership.FeeStatus.UNPAID) + + def test_mark_complete_records_who_and_when(self): + status = mark_complete(self.membership, self.medical, user=self.staff, note="emailed 12 Aug") + + self.assertTrue(status.is_complete) + self.assertEqual(status.completed_by, self.staff) + self.assertIsNotNone(status.completed_at) + self.assertEqual(status.note, "emailed 12 Aug") + + def test_mark_complete_is_idempotent_per_requirement(self): + mark_complete(self.membership, self.photo, user=self.staff) + mark_complete(self.membership, self.photo, user=self.staff) + + self.assertEqual(MemberRequirementStatus.objects.filter(membership=self.membership, requirement=self.photo).count(), 1) + + def test_mark_incomplete_undoes_it_without_deleting_the_row(self): + mark_complete(self.membership, self.photo, user=self.staff, note="handed in at practice") + status = mark_incomplete(self.membership, self.photo) + + self.assertFalse(status.is_complete) + self.assertIsNone(status.completed_at) + self.assertIsNone(status.completed_by) + # The note (and any document) survive the toggle -- it's evidence something + # was received once, even if it needs redoing. + self.assertEqual(status.note, "handed in at practice") + + def test_an_inactive_requirement_does_not_block_onboarding(self): + self.medical.is_active = False + self.medical.save() + + mark_complete(self.membership, self.photo, user=self.staff) + + self.assertTrue(self.membership.onboarding_complete) + + def test_checklist_for_pairs_every_active_requirement_with_its_status_or_none(self): + mark_complete(self.membership, self.photo, user=self.staff) + + checklist = checklist_for(self.membership) + by_requirement = dict(checklist) + + self.assertEqual(len(checklist), 2) + self.assertTrue(by_requirement[self.photo].is_complete) + self.assertIsNone(by_requirement[self.medical]) + + def test_a_second_clubs_requirement_never_applies_here(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + OnboardingRequirement.objects.create(club=other_club, name="Waiver") + + self.assertEqual(self.membership.open_requirement_count, 2) # not 3 + + def test_annotate_onboarding_status_matches_the_per_row_property_across_a_list(self): + second = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Sam", last_name="Lee"), season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + mark_complete(self.membership, self.photo, user=self.staff) + + annotated = annotate_onboarding_status(ClubMembership.objects.filter(club=self.club)) + by_pk = {membership.pk: membership.onboarding_open for membership in annotated} + + self.assertEqual(by_pk[self.membership.pk], 1) + self.assertEqual(by_pk[second.pk], 2) + + def test_annotate_onboarding_status_costs_a_fixed_number_of_queries_regardless_of_list_size(self): + # One for the queryset itself, one for the club's required requirements, one for + # every membership's completed statuses -- flat regardless of how many rows. + for i in range(5): + ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name=f"M{i}", last_name="Roe"), season=self.season) + + with self.assertNumQueries(3): + annotate_onboarding_status(ClubMembership.objects.filter(club=self.club)) + + # --- mark_bypassed --- + def test_mark_bypassed_resolves_the_item_without_marking_it_complete(self): + status = mark_bypassed(self.membership, self.photo, user=self.staff, note="already has a recent one on file") + + self.assertFalse(status.is_complete) + self.assertTrue(status.is_bypassed) + self.assertEqual(status.note, "already has a recent one on file") + self.assertEqual(self.membership.open_requirement_count, 1) + + def test_mark_complete_clears_a_prior_bypass(self): + mark_bypassed(self.membership, self.photo, user=self.staff, note="not needed") + status = mark_complete(self.membership, self.photo, user=self.staff) + + self.assertTrue(status.is_complete) + self.assertFalse(status.is_bypassed) + + def test_mark_bypassed_clears_a_prior_completion(self): + mark_complete(self.membership, self.photo, user=self.staff) + status = mark_bypassed(self.membership, self.photo, user=self.staff, note="turns out not needed") + + self.assertFalse(status.is_complete) + self.assertTrue(status.is_bypassed) + + def test_mark_incomplete_also_clears_a_bypass(self): + mark_bypassed(self.membership, self.photo, user=self.staff, note="not needed") + status = mark_incomplete(self.membership, self.photo) + + self.assertFalse(status.is_complete) + self.assertFalse(status.is_bypassed) + self.assertEqual(self.membership.open_requirement_count, 2) + + # --- blocking_event_kinds --- + def test_blocking_event_kinds_is_empty_when_nothing_blocks_anything(self): + self.assertEqual(blocking_event_kinds(self.membership), set()) + + def test_blocking_event_kinds_collects_kinds_from_every_open_requirement(self): + self.medical.blocked_event_kinds = ["game", "tournament"] + self.medical.save() + self.photo.blocked_event_kinds = ["game"] + self.photo.save() + + self.assertEqual(blocking_event_kinds(self.membership), {"game", "tournament"}) + + def test_blocking_event_kinds_ignores_a_resolved_requirement(self): + self.medical.blocked_event_kinds = ["game"] + self.medical.save() + mark_complete(self.membership, self.medical, user=self.staff) + + self.assertEqual(blocking_event_kinds(self.membership), set()) + + def test_blocking_event_kinds_ignores_a_bypassed_requirement(self): + self.medical.blocked_event_kinds = ["game"] + self.medical.save() + mark_bypassed(self.membership, self.medical, user=self.staff, note="waived") + + self.assertEqual(blocking_event_kinds(self.membership), set()) + + # --- blocked_member_ids_for_event --- + def test_blocked_member_ids_for_event_is_empty_when_nothing_is_configured_to_block(self): + self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), set()) + + def test_blocked_member_ids_for_event_flags_a_member_with_an_open_blocking_requirement(self): + self.medical.blocked_event_kinds = ["game"] + self.medical.save() + + self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), {self.member.pk}) + + def test_blocked_member_ids_for_event_is_kind_specific(self): + self.medical.blocked_event_kinds = ["game"] + self.medical.save() + + self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "training"), set()) + + def test_blocked_member_ids_for_event_excludes_a_member_who_resolved_it(self): + self.medical.blocked_event_kinds = ["game"] + self.medical.save() + mark_complete(self.membership, self.medical, user=self.staff) + + self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), set()) + + def test_blocked_member_ids_for_event_excludes_a_bypassed_requirement_too(self): + self.medical.blocked_event_kinds = ["game"] + self.medical.save() + mark_bypassed(self.membership, self.medical, user=self.staff, note="waived") + + self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), set()) + + # --- approve_all_clean --- + def test_approve_all_clean_activates_a_pending_paid_up_fully_checked_member(self): + pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID) + mark_complete(pending, self.photo, user=self.staff) + mark_bypassed(pending, self.medical, user=self.staff, note="waived") + + activated = approve_all_clean(self.club, self.season) + + pending.refresh_from_db() + self.assertEqual(activated, 1) + self.assertEqual(pending.status, ClubMembership.StatusChoices.ACTIVE) + + def test_approve_all_clean_skips_a_pending_member_with_an_open_requirement(self): + pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID) + mark_complete(pending, self.photo, user=self.staff) + # self.medical left open. + + activated = approve_all_clean(self.club, self.season) + + pending.refresh_from_db() + self.assertEqual(activated, 0) + self.assertEqual(pending.status, ClubMembership.StatusChoices.PENDING) + + def test_approve_all_clean_skips_a_pending_member_who_has_not_paid(self): + pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID) + mark_complete(pending, self.photo, user=self.staff) + mark_complete(pending, self.medical, user=self.staff) + + activated = approve_all_clean(self.club, self.season) + + pending.refresh_from_db() + self.assertEqual(activated, 0) + self.assertEqual(pending.status, ClubMembership.StatusChoices.PENDING) + + def test_approve_all_clean_never_touches_an_already_active_membership(self): + # self.membership is already ACTIVE/PAID with two open requirements -- + # approve_all_clean only ever moves PENDING -> ACTIVE, it doesn't re-check + # or deactivate anyone already active. + activated = approve_all_clean(self.club, self.season) + + self.membership.refresh_from_db() + self.assertEqual(activated, 0) + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE) + + def test_approve_all_clean_ignores_a_guardian_kind_membership(self): + guardian_member = Member.objects.create(first_name="Pat", last_name="Guardian") + ClubMembership.objects.create(club=self.club, member=guardian_member, season=self.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID) + + activated = approve_all_clean(self.club, self.season) + + self.assertEqual(activated, 0) + + def test_approve_all_clean_stamps_activated_at(self): + pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID) + mark_complete(pending, self.photo, user=self.staff) + mark_complete(pending, self.medical, user=self.staff) + + approve_all_clean(self.club, self.season) + + pending.refresh_from_db() + self.assertEqual(pending.activated_at, timezone.localdate()) + + # --- approve_one --- + def test_approve_one_activates_a_clean_pending_membership_and_stamps_activated_at(self): + pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID) + mark_complete(pending, self.photo, user=self.staff) + mark_complete(pending, self.medical, user=self.staff) + + activated = approve_one(pending) + + pending.refresh_from_db() + self.assertTrue(activated) + self.assertEqual(pending.status, ClubMembership.StatusChoices.ACTIVE) + self.assertEqual(pending.activated_at, timezone.localdate()) + + def test_approve_one_refuses_a_paid_but_unchecked_membership(self): + # Fully paid is not enough on its own -- the whole point of this change is + # that fee_status alone never activates; the checklist must be resolved too. + pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID) + mark_complete(pending, self.photo, user=self.staff) + # self.medical left open. + + activated = approve_one(pending) + + pending.refresh_from_db() + self.assertFalse(activated) + self.assertEqual(pending.status, ClubMembership.StatusChoices.PENDING) + self.assertIsNone(pending.activated_at) diff --git a/compose.behind-proxy.yaml b/compose.behind-proxy.yaml index 2b2f348..632336d 100644 --- a/compose.behind-proxy.yaml +++ b/compose.behind-proxy.yaml @@ -24,6 +24,9 @@ services: # this, a rebuild or recreate wipes MEDIA_ROOT even though the container itself keeps # running fine in between. - media_data:/app/media + # Private uploads (e.g. a member's medical certificate) -- see compose.yaml's own + # comment on this volume for why it's absent from every other service here. + - private_media_data:/app/private_media depends_on: db: condition: service_healthy @@ -36,6 +39,32 @@ services: retries: 3 start_period: 20s + worker: + build: . + restart: unless-stopped + env_file: .env.production + # See compose.yaml for what runs here and why. + command: ["celery", "-A", "rosterchief", "worker", "--loglevel=info", "--concurrency=2"] + volumes: + - media_data:/app/media + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + + beat: + build: . + restart: unless-stopped + env_file: .env.production + # Exactly ONE of these across the whole deployment -- see compose.yaml. + command: ["celery", "-A", "rosterchief", "beat", "--loglevel=info"] + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + db: image: postgres:17-alpine restart: unless-stopped @@ -56,8 +85,10 @@ services: redis: image: redis:7-alpine restart: unless-stopped + # Cache AND Celery broker for worker/beat above -- see compose.yaml's redis comment. command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "32mb", "--maxmemory-policy", "allkeys-lru"] volumes: pgdata: media_data: + private_media_data: diff --git a/compose.yaml b/compose.yaml index a38d681..b597107 100644 --- a/compose.yaml +++ b/compose.yaml @@ -40,6 +40,10 @@ services: # this, a rebuild or recreate wipes MEDIA_ROOT even though the container itself keeps # running fine in between. - media_data:/app/media + # Private uploads (e.g. a member's medical certificate -- see rosterchief/storage.py). + # Deliberately NOT mounted into `caddy` below, unlike media_data: nothing should be able + # to serve this except the authenticated Django view that reads it. + - private_media_data:/app/private_media depends_on: db: condition: service_healthy @@ -52,6 +56,38 @@ services: retries: 3 start_period: 20s + worker: + build: . + restart: unless-stopped + env_file: .env.production + # The scheduled platform jobs (see billing/tasks.py, club/tasks.py, events/tasks.py) run + # here, dispatched by `beat` below over the same Redis `web` uses as a cache — see + # rosterchief/settings.py's "Task queue (Celery)" section. Several of these are safe to + # scale; `beat` is not (see its own comment). + command: ["celery", "-A", "rosterchief", "worker", "--loglevel=info", "--concurrency=2"] + volumes: + - media_data:/app/media + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + + beat: + build: . + restart: unless-stopped + env_file: .env.production + # The scheduler -- decides *when* each task in CELERY_BEAT_SCHEDULE fires and hands it to + # a worker. Run exactly ONE of these: two beats would each independently decide it's time + # and every job runs twice (two archive_overdue_clubs runs is two emails to the same club, + # same reasoning as the old crontab's "exactly one node" -- see DEPLOYMENT.md). + command: ["celery", "-A", "rosterchief", "beat", "--loglevel=info"] + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + db: image: postgres:17-alpine restart: unless-stopped @@ -75,10 +111,14 @@ services: redis: image: redis:7-alpine restart: unless-stopped - # Cache only, so nothing here needs to survive a restart. It is not optional though: it - # is what keeps every gunicorn worker agreeing about which feature flags are on. maxmemory - # is a ceiling, not a saving — this is already the smallest process in the stack — but on a - # memory-limited box it should evict cache entries under pressure, not grow unbounded. + # Doubles as the Celery broker/result backend for `worker`/`beat` (see rosterchief/settings.py) + # as well as the cache. maxmemory-policy allkeys-lru is right for a cache — evict rather than + # grow unbounded — but it means a queued task message COULD be evicted under memory pressure + # before a worker consumes it, same as a Redis restart drops anything queued (--save "", + # --appendonly no: nothing here persists by design). Acceptable at this job volume (five + # scheduled tasks a day; a missed one runs at its next scheduled time regardless, per + # CELERY_BEAT_SCHEDULE); if that stops being true, give Celery its own Redis instance rather + # than changing this cache's eviction policy to suit it. command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "32mb", "--maxmemory-policy", "allkeys-lru"] volumes: @@ -86,3 +126,4 @@ volumes: caddy_data: caddy_config: media_data: + private_media_data: diff --git a/controlpanel/context_processors.py b/controlpanel/context_processors.py new file mode 100644 index 0000000..f106474 --- /dev/null +++ b/controlpanel/context_processors.py @@ -0,0 +1,16 @@ +"""So the command bar's status indicator (base.html) can reflect real job health on every +control panel page, not just the dashboard, without every view remembering to pass it. + +Guarded to controlpanel pages only -- unlike features.context_processors.maintenance (a +cached read, cheap anywhere), this runs a real query, and every other page on the platform +(club subdomains, the public site) has no command bar to show it on. +""" + +from .services.jobs import recent_job_failures + + +def job_health(request): + if not (request.resolver_match and request.resolver_match.app_name == "controlpanel"): + return {} + + return {"failed_jobs": recent_job_failures()} diff --git a/controlpanel/services/jobs.py b/controlpanel/services/jobs.py new file mode 100644 index 0000000..efa201d --- /dev/null +++ b/controlpanel/services/jobs.py @@ -0,0 +1,51 @@ +"""Read side of the scheduled-job history for the control panel's Jobs tab and the +Platform dashboard's job log / failed-jobs tile. + +``features.jobs.JOB_REGISTRY`` is what a job *is* (label, description, schedule); +``features.models.JobRun`` is what actually happened, written by the Celery signal handlers +in features/signals.py. This module just joins the two for a template. +""" + +from datetime import timedelta + +from django.utils import timezone + +from features.jobs import JOB_REGISTRY +from features.models import JobRun + +#: Runs shown per job on the Jobs tab -- enough to see a pattern (a job that fails every +#: third day, say) without the page turning into a full audit log. +RECENT_RUNS = 10 + +#: What counts as "recent" for the dashboard's failed-jobs KPI tile. +FAILURE_WINDOW_HOURS = 24 + +#: Rows in the Platform dashboard's job log card. +JOB_LOG_ROWS = 8 + + +def job_overview(): + """One entry per registered job, its most recent runs, and a shortcut to the latest.""" + return [ + { + "name": name, + "label": meta["label"], + "description": meta["description"], + "schedule": meta["schedule"], + "runs": (runs := list(JobRun.objects.filter(name=name)[:RECENT_RUNS])), + "latest": runs[0] if runs else None, + } + for name, meta in JOB_REGISTRY.items() + ] + + +def recent_job_failures(hours=FAILURE_WINDOW_HOURS): + """Failures in the last `hours` -- the platform-health "failed jobs" signal. A number + that sits here is exactly what a dead beat schedule or a broken task looks like.""" + since = timezone.now() - timedelta(hours=hours) + return JobRun.objects.filter(status=JobRun.Status.FAILURE, started_at__gte=since) + + +def recent_job_runs(limit=JOB_LOG_ROWS): + """Every job's runs, most recent first, for the dashboard's Job log card.""" + return JobRun.objects.all()[:limit] diff --git a/controlpanel/services/statistics.py b/controlpanel/services/statistics.py index b71b515..f892988 100644 --- a/controlpanel/services/statistics.py +++ b/controlpanel/services/statistics.py @@ -104,6 +104,42 @@ def clubs_with_health(queryset=None, today=None, now=None): ) +#: Risk tiers for the dashboard's "Club health" table, high risk first. Derived from signals +#: `clubs_with_health` already annotates -- no separate query, and nothing here is invented: +#: a club with no season covering today cannot take a signup, and dues past their grace date +#: are exactly what the archive job is about to act on. +RISK_HIGH, RISK_WATCH, RISK_OK = "high", "watch", "ok" + + +def club_risk(club, today): + """The risk tier, plus a human reason naming exactly which signal tripped it -- so the + dashboard can show *why*, not just a colour. Checked in the same order as the tier + logic below: the first matching condition is the one reported.""" + if not club.has_season: + return RISK_HIGH, _("No season covers today") + if club.dues_grace_until is not None and club.dues_grace_until < today: + return RISK_HIGH, _("Dues overdue past grace") + if not club.upcoming_events: + return RISK_WATCH, _("No events in the next 30 days") + if club.dues_owed: + return RISK_WATCH, _("Dues outstanding") + return RISK_OK, _("Nothing needs attention") + + +def clubs_by_risk(queryset=None, today=None): + """`clubs_with_health`, ordered highest risk first -- the dashboard's Club health table + is "sorted by risk" per the design, and risk is exactly the thing that table is for.""" + today = today or timezone.localdate() + order = {RISK_HIGH: 0, RISK_WATCH: 1, RISK_OK: 2} + + clubs = list(clubs_with_health(queryset, today=today)) + for club in clubs: + club.risk, club.risk_reason = club_risk(club, today) + clubs.sort(key=lambda club: order[club.risk]) + + return clubs + + def platform_totals(): return { "clubs": Club.objects.active().count(), diff --git a/controlpanel/templates/controlpanel/_auth_base.html b/controlpanel/templates/controlpanel/_auth_base.html new file mode 100644 index 0000000..402c58e --- /dev/null +++ b/controlpanel/templates/controlpanel/_auth_base.html @@ -0,0 +1,112 @@ +{% load static lucide ui %} + +{% comment %} + Standalone shell for every sitewide allauth screen -- login, password change/reset, + MFA, passkeys, recovery codes -- plus 403.html/maintenance.html, rendered whenever + there is no club tenant (see club/context_processors.py: this is + PLATFORM_BASE_TEMPLATE). Same industrial design language as controlpanel/base.html -- + dark ink chrome, Barlow/Barlow Condensed/IBM Plex Mono, assets/controlpanel.css -- + but deliberately simpler: one centred card on a dark page, not a full command-bar + app shell, since these are public entrance screens for the whole platform (every + club admin and base-domain account), not the control panel itself. + + Block names match what templates/_base.html used to provide (head_title, extra_head, + main, extra_body) rather than inventing new ones: templates/allauth/layouts/base.html + and templates/403.html/maintenance.html target those names directly, and both are + shared with the club-branded skin (_club_base.html, still on assets/app.css and real + daisyUI, untouched) -- give them a different block name here and they would have + nothing to override on this side of the fork. +{% endcomment %} + + + + + + + + {% block head_title %}{% endblock head_title %} · RosterChief + + + + {% block extra_head %}{% endblock extra_head %} + + + + {# Explicit bg-ink here too, not just on : the white-on-dark brand mark must stay legible on its own. #} + + +
+ {% if messages %} +
+ {% for message in messages %} + {% with alert=message|as_alert %} + + {% endwith %} + {% endfor %} +
+ {% endif %} + + {% block main %}{% endblock main %} +
+ +

© {% now "Y" %} RosterChief

+ + {% comment %} + A TOTP code is 6 characters, a recovery code 8, and allauth accepts either in + the same field (templates/allauth/elements/fields.html). The boxed .otp layout + only fits six, so past that this falls back to a plain .input-lg rather than + letting the text spill out of the boxes. + + The real 's own text is invisible (assets/controlpanel.css: `.otp input` + is `color: transparent`, only its caret shows) -- this writes each typed + character into its matching directly instead, which is exact by + construction. A pure-CSS letter-spacing overlay (spacing the real glyphs to + match the box pitch) was tried first and drifted more with every character + typed, in a way font-metric tuning couldn't reliably fix. + {% endcomment %} + + + {% comment %} + allauth puts page-level scripts and out-of-form markup here -- notably the + hidden `mfa_login` form the passkey button submits on the login page. Without + this block that form is never rendered and "Sign in with a passkey" is dead. + {% endcomment %} + {% block extra_body %}{% endblock extra_body %} + + diff --git a/controlpanel/templates/controlpanel/_club_admins_card.html b/controlpanel/templates/controlpanel/_club_admins_card.html index d88fa87..343a380 100644 --- a/controlpanel/templates/controlpanel/_club_admins_card.html +++ b/controlpanel/templates/controlpanel/_club_admins_card.html @@ -4,38 +4,36 @@ Club-scoped admins, and the modals to add one / confirm removing one. Included with `club`, `admins`, `admin_form` already in context. {% endcomment %} -
-
-
-

{% lucide "shield-user" size=18 %} Club admins

- -
-
-
- +
+
+ Club admins + +
+
+
+ + + + + + + + + {% for role in admins %} - - - + + + - - - {% for role in admins %} - - - - - - {% empty %} - - - - {% endfor %} - -
NameEmail
NameEmail{{ role.member }}{{ role.member.user.email|default:"—" }} + +
{{ role.member }}{{ role.member.user.email|default:"—" }} - -
No admins yet.
- + {% empty %} + + No admins yet. + + {% endfor %} + + diff --git a/controlpanel/templates/controlpanel/_club_billing_card.html b/controlpanel/templates/controlpanel/_club_billing_card.html index f230280..cdfc806 100644 --- a/controlpanel/templates/controlpanel/_club_billing_card.html +++ b/controlpanel/templates/controlpanel/_club_billing_card.html @@ -6,27 +6,27 @@ `dues`, `today`, `subscription_form`, `open_period_form`, `open_period_blurb` already in context. {% endcomment %} -
-
-
-

{% lucide "receipt-euro" size=18 %} Billing

-
- + {% if not subscription %} + - {% if not subscription %} - - {% endif %} - {% if subscription %} - - {% endif %} -
+ {% endif %} + {% if subscription %} + + {% endif %}
+
+
{% comment %} Paying up does not un-archive a club on its own -- restoring is a deliberate act, because a club can also be archived by hand for reasons that have nothing to do @@ -34,20 +34,20 @@ instead of something you have to remember to go and check. {% endcomment %} {% if club.is_archived and dues_settled %} -
+
{% lucide "circle-check" size=16 %} This club is archived but owes nothing. Reactivating will restore access and open its next period.
{% endif %} {% if not subscription %} -

This club is not billed for anything. Put it on a plan to start.

+

This club is not billed for anything. Put it on a plan to start.

{% else %} -

- On plan {{ subscription.plan.name }}. +

+ On plan {{ subscription.plan.name }}. {% if subscription.trial_ends_at %} {% lucide "hourglass" size=12 %} Trial - On trial until {{ subscription.trial_ends_at|date:"j M Y" }}, then switches to {{ subscription.post_trial_plan.name }}. + On trial until {{ subscription.trial_ends_at|date:"j M Y" }}, then switches to {{ subscription.post_trial_plan.name }}. {% endif %} {{ subscription.plan.duration_months }}-month periods, archived {{ subscription.plan.grace_days }} days after a period starts if unpaid. {% if subscription.auto_renew %} @@ -76,28 +76,32 @@ {% for due in dues %} - + {{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }} -

{{ due.plan.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}
+
+ {{ due.plan.name }} · {{ due.invoice.number }} +
grace to {{ due.grace_until|date:"j M Y" }}
+
- €{{ due.amount|floatformat:2 }} - €{{ due.amount_paid|floatformat:2 }} + €{{ due.amount|floatformat:2 }} + €{{ due.amount_paid|floatformat:2 }} {% if due.status == "paid" %} {% lucide "check" size=12 %} Paid {% elif due.status == "waived" %} - {% lucide "check" size=12 %} Waived + {% lucide "check" size=12 %} Waived {% elif due.grace_until < today %} {% lucide "triangle-alert" size=12 %} Overdue {% elif due.period_end < today %} {% lucide "hourglass" size=12 %} In grace {% else %} - {{ due.get_status_display }} + {{ due.get_status_display }} {% endif %} - + +
{% if due.is_owing %} - + {% if not due.payments.all %}
{% csrf_token %} @@ -105,11 +109,12 @@
{% endif %} {% endif %} - {% lucide "file-down" size=14 %} Download invoice + {% lucide "file-down" size=14 %} Download invoice +
{% for payment in due.payments.all %} - + {% lucide "corner-down-right" size=12 %} {{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %} @@ -120,7 +125,7 @@ {% endfor %} {% empty %} - No periods billed yet. + No periods billed yet. {% endfor %} diff --git a/controlpanel/templates/controlpanel/_club_features_card.html b/controlpanel/templates/controlpanel/_club_features_card.html index e335918..710798b 100644 --- a/controlpanel/templates/controlpanel/_club_features_card.html +++ b/controlpanel/templates/controlpanel/_club_features_card.html @@ -4,42 +4,34 @@ Which feature flags apply to this club. Included with `club`, `flags` (from `flags_for_club`) already in context. {% endcomment %} -
-
-
-

{% lucide "toggle-right" size=18 %} Features

- {% lucide "wrench" size=14 %} Manage features -
-
- - - {% for entry in flags %} - - - - - - {% empty %} - - - - {% endfor %} - -
{{ entry.flag.name }}{{ entry.flag.note|default:"—" }} - {% if entry.overridden %} - {# `everyone` overrides club targeting, so a per-club toggle would be a lie. #} - - {% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %} - - {% else %} -
- {% csrf_token %} - -
- {% endif %} -
No features defined yet.
-
+
+ +
+ {% for entry in flags %} +
+
+
{{ entry.flag.name }}
+
{{ entry.flag.note|default:"—" }}
+
+ {% if entry.overridden %} + {# `everyone` overrides club targeting, so a per-club toggle would be a lie. #} + + {% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %} + + {% else %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ {% empty %} +
No features defined yet.
+ {% endfor %}
diff --git a/controlpanel/templates/controlpanel/_club_health_table.html b/controlpanel/templates/controlpanel/_club_health_table.html index eada2ec..85f0ef1 100644 --- a/controlpanel/templates/controlpanel/_club_health_table.html +++ b/controlpanel/templates/controlpanel/_club_health_table.html @@ -28,64 +28,56 @@ {% for club in clubs %} -
-
- {% if club.logo %} - {{ club.name }} - {% else %} -
-
- {{ club.initials }} -
-
- {% endif %} -
+
+ {% if club.logo %} + {{ club.name }} + {% else %} +
+ {{ club.initials }} +
+ {% endif %} -
- {{ club.name }} -
{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}
+
+ {{ club.name }} +
{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}
-
+
{% if club.is_archived %} - {% lucide "archive" size=14 %} archived + {% lucide "archive" size=12 %} Archived {% else %} {% if not club.has_season %} - {% lucide "calendar-x" size=14 %} no seasons + {% lucide "calendar-x" size=12 %} No seasons {% endif %} {% if not club.upcoming_events %} - {% lucide "moon-star" size=14 %}dormant + {% lucide "moon-star" size=12 %} Dormant {% endif %} {% endif %}
- {{ club.active_members }} - -
+ {{ club.active_members }} + +
{% if not club.admin_count %} - {% lucide "triangle-alert" size=16 %} + {% lucide "triangle-alert" size=14 %} {% endif %} - {{ club.admin_count }} + {{ club.admin_count }}
- {{ club.team_count }} - {{ club.upcoming_events }} + {{ club.team_count }} + {{ club.upcoming_events }} - - {% if club.plan_name %} - {{ club.plan_name|lower }} - {% else %} - - - {% endif %} + + {{ club.plan_name|lower|default:"—" }} -
+
{% if not club.dues_owed %} {% if club.plan_name %} {% comment %} @@ -94,40 +86,36 @@ for a paid period AND a waived one (both cover the club, they just differ in how). No covered period at all (only cancelled dues, say) shows a dash. {% endcomment %} -
- {% if club.covered_status == "waived" %} - waived - {% elif club.covered_until %} - paid - {% else %} - - - {% endif %} -
+ {% if club.covered_status == "waived" %} + Waived + {% elif club.covered_until %} + Paid + {% else %} + + {% endif %} {% else %} - - + {% endif %} {% else %} - €{{ club.dues_owed|floatformat:2 }} + €{{ club.dues_owed|floatformat:2 }} {% if club.dues_grace_until < today %} - overdue + Overdue {% elif club.dues_period_end < today %} - grace + Grace {% endif %} {% endif %}
- - {{ club.covered_until|date:"j M Y"|default:"-" }} - + {{ club.covered_until|date:"j M Y"|default:"—" }} - {% lucide "pencil" size=14 %} Edit + {% lucide "pencil" size=12 %} Edit {% empty %} - {{ empty_message|default:"No clubs yet." }} + {{ empty_message|default:"No clubs yet." }} {% endfor %} diff --git a/controlpanel/templates/controlpanel/_club_home_location_card.html b/controlpanel/templates/controlpanel/_club_home_location_card.html index 6e0a449..b7e3962 100644 --- a/controlpanel/templates/controlpanel/_club_home_location_card.html +++ b/controlpanel/templates/controlpanel/_club_home_location_card.html @@ -6,31 +6,31 @@ Locations page shows, no separate sync step involved. Included with `club`, `home_location`, `home_location_form` already in context. {% endcomment %} -
-
-
-

{% lucide "map-pin" size=18 %} Home location

- -
+
+
+ Home location + +
+
{% if home_location %} -
+
-
Name
-
{{ home_location.name }}
+
Name
+
{{ home_location.name }}
-
Address
-
{{ home_location.address }}, {{ home_location.zip_code }} {{ home_location.city }}, {{ home_location.country }}
+
Address
+
{{ home_location.address }}, {{ home_location.zip_code }} {{ home_location.city }}, {{ home_location.country }}
{% else %} -

Not set yet. Once set, events at this location can be recognised as home games.

+

Not set yet. Once set, events at this location can be recognised as home games.

{% endif %}
diff --git a/controlpanel/templates/controlpanel/_nav_items.html b/controlpanel/templates/controlpanel/_nav_items.html index c3d9465..76d83cb 100644 --- a/controlpanel/templates/controlpanel/_nav_items.html +++ b/controlpanel/templates/controlpanel/_nav_items.html @@ -1,37 +1,17 @@ -{% load lucide %} - {% comment %} - The panel's navigation, in one place: the sidebar renders it on a wide screen and the - collapsed menu renders it on a narrow one. Two copies of a link list is how a new section - ends up reachable on a desktop and invisible on a phone. + The command bar's tabs. One place, included by base.html, so a new section is a new +
  • -equivalent here and nowhere else -- see design_handoff_rosterchief_platform/README.md + for the "platform / clubs / features / billing / admins / jobs" tab order this mirrors. - `menu-active` is daisyUI 5's active state; hover and focus come with `.menu` itself. + Active tab: `bg-steel` fill with a 2px `ice` bottom border, per the handoff's command-bar + spec. Inactive tabs are plain `text-on-dark-dim`. {% endcomment %} -
  • - - {% lucide "layout-dashboard" size=16 %} Dashboard - -
  • -
  • - - {% lucide "building-2" size=16 %} Clubs - -
  • -
  • - - {% lucide "receipt-euro" size=16 %} Billing - -
  • -
  • - - {% lucide "toggle-right" size=16 %} Features - -
  • +platform +clubs +features +billing {% if user.is_superuser %} - {# Superusers only, exactly as the view is gated: a link staff cannot follow is a lie. #} -
  • - - {% lucide "user-cog" size=16 %} Platform admins - -
  • + {# Superusers only, exactly as the view is gated: a tab staff cannot follow is a lie. #} + admins {% endif %} +jobs diff --git a/controlpanel/templates/controlpanel/admins.html b/controlpanel/templates/controlpanel/admins.html index 2d2d9ba..6477caa 100644 --- a/controlpanel/templates/controlpanel/admins.html +++ b/controlpanel/templates/controlpanel/admins.html @@ -1,11 +1,13 @@ {% extends "controlpanel/base.html" %} {% load lucide ui %} -{% block heading %}Platform admins{% endblock heading %} +{% block panel_title %}Admins{% endblock panel_title %} -{% block subheading %} -

    Staff run the panel. Superusers additionally manage this list.

    -{% endblock subheading %} +{% block breadcrumb %} + admins + | + {{ admins|length }} platform admin{{ admins|length|pluralize }} +{% endblock breadcrumb %} {% block actions %} @@ -15,66 +17,80 @@ {% url 'controlpanel:admin_add' as admin_add_url %} {% include "controlpanel/_modal_form.html" with modal_id="admin_add_modal" title="Grant platform access" form=admin_form action_url=admin_add_url submit_label="Grant access" submit_icon="user-plus" blurb="Platform admins can manage every club. They must set up two-factor authentication before they can sign in." %} -
    -
    -
    - - - - - - - - - - - - {% for admin in admins %} - - - - - - - - {% empty %} - - - - {% endfor %} - -
    UserStaffSuperuserLast login
    -
    {{ admin.email }}
    - {% if admin.pk == user.pk %} -
    That's you
    {% endif %} -
    -
    - {% csrf_token %} - - - -
    -
    -
    - {% csrf_token %} - - - -
    -
    {{ admin.last_login|date:"j M Y"|default:"Never" }} - -
    No platform admins.
    -
    - - {% comment %} Dialogs live outside the table: may only contain elements. {% endcomment %} - {% for admin in admins %} - {% url 'controlpanel:admin_revoke' admin.pk as admin_revoke_url %} - {% include "controlpanel/_confirm_modal.html" with modal_id=admin.pk|dom_id:"admin_revoke_modal" title="Revoke platform access" body="Revoke platform access for "|add:admin.email|add:"? They will no longer be able to reach the control panel." action_url=admin_revoke_url submit_label="Revoke" submit_icon="user-minus" %} - {% endfor %} +
    +
    + Platform admins
    +
    + + + + + + + + + + + + {% for admin in admins %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    UserStaffSuperuserLast login
    +
    {{ admin.email }}
    + {% if admin.pk == user.pk %} + + {% lucide "badge-check" size=10 %} That's you + + {% endif %} +
    +
    + {% csrf_token %} + + + +
    +
    +
    + {% csrf_token %} + + + +
    +
    {{ admin.last_login|date:"j M Y"|default:"Never" }} + +
    No platform admins.
    +
    + + {% comment %} Dialogs live outside the table: may only contain elements. {% endcomment %} + {% for admin in admins %} + {% url 'controlpanel:admin_revoke' admin.pk as admin_revoke_url %} + {% include "controlpanel/_confirm_modal.html" with modal_id=admin.pk|dom_id:"admin_revoke_modal" title="Revoke platform access" body="Revoke platform access for "|add:admin.email|add:"? They will no longer be able to reach the control panel." action_url=admin_revoke_url submit_label="Revoke" submit_icon="user-minus" %} + {% endfor %}
    {% endblock panel %} diff --git a/controlpanel/templates/controlpanel/base.html b/controlpanel/templates/controlpanel/base.html index cb3597a..b3f8ba4 100644 --- a/controlpanel/templates/controlpanel/base.html +++ b/controlpanel/templates/controlpanel/base.html @@ -1,90 +1,131 @@ -{% extends "_platform_base.html" %} -{% load lucide %} +{% load static lucide ui %} -{% block title %} - {% block panel_title %}Control panel{% endblock panel_title %} · RosterChief -{% endblock title %} +{% comment %} + Standalone shell for the control panel -- does NOT extend templates/_base.html or + _platform_base.html, and does not load assets/app.css or daisyUI. This surface is + platform-staff-only, desktop-only (see design_handoff_rosterchief_platform/README.md: + "Control panel ... Desktop only"), and deliberately never club-branded, so it gets its own + document shell, its own stylesheet (assets/controlpanel.css -> static/css/controlpanel.css) + and its own type system (Barlow / Barlow Condensed / IBM Plex Mono) rather than inheriting + the club-facing app's theme. No mobile nav, no theme toggle -- neither exists in the design. +{% endcomment %} + + + + + -{% block nav_toggle %} - -{% endblock nav_toggle %} + + {% block title %}{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief{% endblock title %} + -{% comment %} Kept in the navbar itself only at `lg`+, where there's no hamburger drawer to hold them instead -- see the comment on `nav_icons_class` in _base.html. {% endcomment %} -{% block nav_icons_class %}hidden items-center lg:flex{% endblock nav_icons_class %} + + {% block extra_head %}{% endblock extra_head %} + -{% block menu %} {% comment %} - Outside
    , so it never scrolls with the content. Its own overflow-y-auto is for - the day the menu itself grows taller than the screen. + App-shell layout: the command bar and breadcrumb strip are pinned,
    is the only + scrolling region -- same reasoning templates/_base.html gives for the club-facing shell. {% endcomment %} - -{% endblock menu %} + +
    + + {# The real mark, not the .crest clip-path fallback (that's for clubs with no logo of their own) -- white-on-dark variant for this bar. #} + + RosterChief + control + -{% block main %} - {# Below `lg` the sidebar is hidden and {% block nav_toggle %} above opens this instead. #} - - - - + - {% if maintenance_on %} -
    - {% lucide "wrench" size=20 %} - - The platform is currently closed for maintenance. - Clubs see a maintenance page and the scheduled jobs are standing down. - - {% lucide "unlock" size=16 %} Reopen platform -
    - {% endif %} -
    -
    - {% block logo %}{% endblock logo %} +
    -
    -

    - {% block heading %}Control panel{% endblock heading %} -

    - {% block subheading %}{% endblock subheading %} +
    + {% block actions %}{% endblock actions %}
    + + {% comment %} + Native
    / disclosure -- no JS needed for a menu this small. + Only the account/system info on the right, grouped with the status indicator. + {% endcomment %} + + + {% comment %} + Real, not decorative. Three states, most severe wins: maintenance (reflects + features.models.Maintenance, the same switch the Features tab's "Close the + platform" action controls) beats recent job failures (failed_jobs, from + controlpanel.context_processors.job_health -- see features/models.JobRun) + beats "all systems ok". Links to where you'd go to do something about it. + {% endcomment %} + {% if maintenance_on %} + + + maintenance mode + + {% elif failed_jobs %} + + + {{ failed_jobs|length }} job failure{{ failed_jobs|length|pluralize }} + + {% else %} +
    + + all systems ok +
    + {% endif %} +
    + +
    + {% block breadcrumb %}control{% endblock breadcrumb %} +
    + {% block strip_right %}{% endblock strip_right %}
    -
    - {% block actions %}{% endblock actions %} -
    -
    +
    +
    + {% if maintenance_on %} + + {% endif %} - {% block panel %}{% endblock panel %} -{% endblock main %} + {% if messages %} +
    + {% for message in messages %} + {% with alert=message|as_alert %} + + {% endwith %} + {% endfor %} +
    + {% endif %} + + {% block panel %}{% endblock panel %} +
    +
    + + {% block extra_body %}{% endblock extra_body %} + + diff --git a/controlpanel/templates/controlpanel/billing.html b/controlpanel/templates/controlpanel/billing.html index b0c8aa9..7671baa 100644 --- a/controlpanel/templates/controlpanel/billing.html +++ b/controlpanel/templates/controlpanel/billing.html @@ -1,147 +1,182 @@ {% extends "controlpanel/base.html" %} -{% load lucide ui %} +{% load i18n lucide ui %} -{% block heading %}Billing{% endblock heading %} +{% comment %} + Platform billing: RosterChief charging the clubs (not clubs charging their members -- + see billing/services). Two tables: every Plan (with its dated price history) and every + Due currently owing across every club. Every action here already existed as a modal or + a link before this restyle; nothing was added or removed, only reskinned to the + industrial control-panel vocabulary (assets/controlpanel.css). +{% endcomment %} + +{% block panel_title %}{% trans "Billing" %}{% endblock panel_title %} + +{% block breadcrumb %} + billing + | + {{ plans|length }} plan{{ plans|length|pluralize }} + | + {{ owing|length }} club{{ owing|length|pluralize }} owing +{% endblock breadcrumb %} {% block actions %} - + {% endblock actions %} {% block panel %} {% url 'controlpanel:plan_create' as plan_create_url %} - {% include "controlpanel/_modal_form.html" with modal_id="plan_create_modal" title="New plan" form=plan_form action_url=plan_create_url submit_label="Create plan" submit_icon="plus" box_class="max-w-2xl" two_columns=True %} + {% trans "New plan" as new_plan_title %} + {% trans "Create plan" as create_plan_label %} + {% include "controlpanel/_modal_form.html" with modal_id="plan_create_modal" title=new_plan_title form=plan_form action_url=plan_create_url submit_label=create_plan_label submit_icon="plus" box_class="max-w-2xl" two_columns=True %} -
    -
    -

    {% lucide "layers" size=18 %} Plans

    + {# --- Plans ----------------------------------------------------------- #} +
    +
    + {% trans "Plans" %} + {% comment %} Prices are dated, not edited. A rate change is a new row with a future active_from; every period already opened keeps the amount it was billed at, - so raising the price cannot rewrite an invoice you have already sent. + so raising the price cannot rewrite an invoice already sent. {% endcomment %} -

    A rate change only takes effect as of a certain date. Periods already billed keep the amount they were issued at.

    -
    - - + {% trans "rate changes apply from a future date — open periods keep the amount they were billed at" %} + +
    +
    + + + + + + + + + + + {% for plan in plans %} - - - - - + + {% comment %} + Named for what each measures from, because that is the easy + thing to get wrong: grace runs from the period START, not its + end. + {% endcomment %} + + + + - - - {% for plan in plans %} - - - {% comment %} - Named for what each measures from, because that is the easy thing to - get wrong: grace runs from the period START, not its end. - {% endcomment %} - - - - - - {% empty %} - - - - {% endfor %} - -
    {% trans "Plan" %}{% trans "Timing" %}{% trans "Clubs" %}{% trans "Prices" %}
    PlanClocksClubsPrices +
    + {{ plan.name }} + {% if plan.is_trial %}{% trans "Trial" %}{% endif %} + {% if not plan.is_active %}{% trans "Retired" %}{% endif %} +
    + {% if plan.description %}
    {{ plan.description }}
    {% endif %} +
    +
    {% blocktrans count counter=plan.duration_months %}{{ counter }} month{% plural %}{{ counter }} months{% endblocktrans %}
    +
    {% blocktrans with days=plan.renewal_lead_days %}billed {{ days }}d before start{% endblocktrans %}
    +
    {% blocktrans with days=plan.grace_days %}grace {{ days }}d after start{% endblocktrans %}
    +
    {{ plan.club_count }} + {% for price in plan.prices.all %} +
    + € {{ price.amount|floatformat:2 }} + {% blocktrans with active_from=price.active_from|date:"Y-m-d" %}from {{ active_from }}{% endblocktrans %} + {% if price.active_from > today %}{% trans "Scheduled" %}{% endif %} +
    + {% empty %} + {% trans "No price — cannot be billed" %} + {% endfor %} +
    +
    + + + {% lucide "trash-2" size=13 %} {% trans "Delete" %} +
    +
    -
    {{ plan.name }}
    - {% if plan.is_trial %}Trial{% endif %} - {% if not plan.is_active %}Retired{% endif %} - {% if plan.description %} -
    {{ plan.description }}
    {% endif %} -
    -
    {{ plan.duration_months }} month{{ plan.duration_months|pluralize }} long
    -
    billed {{ plan.renewal_lead_days }}d before it starts
    -
    archived {{ plan.grace_days }}d after it starts
    -
    {{ plan.club_count }} - {% for price in plan.prices.all %} -
    - €{{ price.amount|floatformat:2 }} - from {{ price.active_from|date:"j M Y" }} - {% if price.active_from > today %}Scheduled{% endif %} -
    - {% empty %} - No price — cannot be billed - {% endfor %} -
    - - - {% lucide "trash-2" size=14 %} Delete -
    No plans yet.
    -
    + {% empty %} + + {% trans "No plans yet." %} + + {% endfor %} + +
    {% comment %} Dialogs live outside the table: may only contain elements. {% endcomment %} {% for plan in plans %} {% url 'controlpanel:plan_price_create' plan.pk as plan_price_url %} - {% include "controlpanel/_modal_form.html" with modal_id=plan.pk|dom_id:"plan_price_modal" title="New price — "|add:plan.name form=plan.price_form action_url=plan_price_url submit_label="Add price" submit_icon="euro" %} + {% blocktrans asvar plan_price_title with plan=plan.name %}New price — {{ plan }}{% endblocktrans %} + {% trans "Add price" as add_price_label %} + {% include "controlpanel/_modal_form.html" with modal_id=plan.pk|dom_id:"plan_price_modal" title=plan_price_title form=plan.price_form action_url=plan_price_url submit_label=add_price_label submit_icon="euro" %} {% url 'controlpanel:plan_update' plan.pk as plan_update_url %} - {% include "controlpanel/_modal_form.html" with modal_id=plan.pk|dom_id:"plan_edit_modal" title="Edit "|add:plan.name form=plan.edit_form action_url=plan_update_url submit_label="Save" submit_icon="check" box_class="max-w-2xl" two_columns=True %} + {% blocktrans asvar plan_edit_title with plan=plan.name %}Edit {{ plan }}{% endblocktrans %} + {% trans "Save" as save_label %} + {% include "controlpanel/_modal_form.html" with modal_id=plan.pk|dom_id:"plan_edit_modal" title=plan_edit_title form=plan.edit_form action_url=plan_update_url submit_label=save_label submit_icon="check" box_class="max-w-2xl" two_columns=True %} {% endfor %} -
    -
    -

    {% lucide "receipt-euro" size=18 %} Owed

    -
    - - + {# --- Owed -------------------------------------------------------------- #} +
    +
    + {% trans "Owed" %} + + {% blocktrans count counter=owing|length %}{{ counter }} period outstanding{% plural %}{{ counter }} periods outstanding{% endblocktrans %} +
    +
    +
    + + + + + + + + + + + {% for due in owing %} - - - - - + + + + + - - - {% for due in owing %} - - - - - - - - {% empty %} - - - - {% endfor %} - -
    {% trans "Club" %}{% trans "Period" %}{% trans "Balance" %}{% trans "Status" %}
    ClubPeriodOwedStatus + {{ due.club.name }} +
    {{ due.plan.name }}
    +
    + {{ due.period_start|date:"Y-m-d" }} → {{ due.period_end|date:"Y-m-d" }} +
    {% blocktrans with grace_until=due.grace_until|date:"Y-m-d" %}grace to {{ grace_until }}{% endblocktrans %}
    +
    € {{ due.balance|floatformat:2 }} + {% if due.grace_until < today %} + {% lucide "triangle-alert" size=11 %} {% trans "Overdue" %} + {% elif due.period_end < today %} + {% lucide "hourglass" size=11 %} {% trans "In grace" %} + {% else %} + {{ due.get_status_display }} + {% endif %} + +
    + + {% lucide "file-down" size=13 %} {% trans "Invoice" %} +
    +
    - {{ due.club.name }} -
    {{ due.plan.name }}
    -
    - {{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }} -
    Grace to {{ due.grace_until|date:"j M Y" }}
    -
    €{{ due.balance|floatformat:2 }} - {% if due.grace_until < today %} - {% lucide "triangle-alert" size=12 %} Overdue - {% elif due.period_end < today %} - {% lucide "hourglass" size=12 %} In grace - {% else %} - {{ due.get_status_display }} - {% endif %} - - - {% lucide "file-down" size=14 %} Download invoice -
    Nothing outstanding.
    -
    + {% empty %} + + {% trans "Nothing outstanding." %} + + {% endfor %} + +
    {% comment %} Dialogs live outside the table: may only contain elements. {% endcomment %} {% for due in owing %} {% url 'controlpanel:due_pay' due.pk as due_pay_url %} - {% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment — "|add:due.club.name form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %} + {% blocktrans asvar due_pay_title with club=due.club.name %}Record payment — {{ club }}{% endblocktrans %} + {% trans "Record payment" as record_payment_label %} + {% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title=due_pay_title form=due.payment_form action_url=due_pay_url submit_label=record_payment_label submit_icon="banknote" %} {% endfor %} {% endblock panel %} diff --git a/controlpanel/templates/controlpanel/club_detail.html b/controlpanel/templates/controlpanel/club_detail.html index bfc84cd..7db2bc3 100644 --- a/controlpanel/templates/controlpanel/club_detail.html +++ b/controlpanel/templates/controlpanel/club_detail.html @@ -1,67 +1,77 @@ {% extends "controlpanel/base.html" %} {% load static lucide %} -{% block logo %} - {% if club.logo %} - {% comment %} - The ring is the club's own primary colour, same as on the club's own subdomain -- - but the control panel never injects a page-wide --color-primary override (it must - not dress itself up as the club), so it's set here as a locally-scoped custom - property instead: it only reaches this element and its children, not the rest of - the panel's buttons and badges. - {% endcomment %} -
    -
    - -
    -
    - {% else %} -
    -
    - {{ club.initials }} -
    -
    - {% endif %} -{% endblock logo %} +{% block panel_title %}{{ club.name }}{% endblock panel_title %} -{% block heading %}{{ club.name }}{% endblock heading %} - -{% block subheading %} - {{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}{% if club.legal_name %} · {{ club.legal_name }}{% endif %} - {% if club.is_archived %} - Archived - {% endif %} -{% endblock subheading %} +{% block breadcrumb %} + clubs + / + {{ club.slug }} + / + settings +
    + id {{ club.pk|stringformat:"s"|slice:":8" }} + | + created {{ club.created|date:"Y-m-d" }} +{% endblock breadcrumb %} {% block actions %} - {% lucide "pencil" size=16 %} Edit - {% lucide "external-link" size=16 %} Open + {% lucide "pencil" size=14 %} Edit + {% lucide "external-link" size=14 %} Open {% if club.is_archived %}
    {% csrf_token %} - +
    {% else %}
    {% csrf_token %} - +
    {% endif %} {% endblock actions %} {% block panel %} + {# --- club header --------------------------------------------------- #} +
    + {% comment %} + The ring is the club's own primary colour, same as on the club's own subdomain -- + but the control panel never injects a page-wide --color-primary override (it must + not dress itself up as the club), so it's set here as a locally-scoped custom + property instead: it only reaches this element, not the rest of the panel's + buttons and badges. + {% endcomment %} + {% if club.logo %} +
    + {{ club.name }} +
    + {% else %} +
    + {{ club.initials }} +
    + {% endif %} + +
    +
    {{ club.name }}
    +
    + {{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}{% if club.legal_name %} · {{ club.legal_name }}{% endif %} + {% if club.is_archived %} + Archived + {% endif %} +
    +
    +
    + {% if club.is_archived %} -
    - {% lucide "alert-triangle" size=20 %} +
    + {% lucide "alert-triangle" size=18 %} This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.
    {% endif %} {% if attention.no_season %} -
    - {% lucide "calendar-x" size=20 %} - - No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert. - +
    + {% lucide "calendar-x" size=18 %} + No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert.
    {% endif %} @@ -70,132 +80,110 @@ the club's setup, not a statistic: with nobody in a management position the access service grants no authority over that team, so nobody can pick the squad. {% endcomment %} -
    - {% comment %}
    -
    -
    {% lucide "banknote" size=16 %} Outstanding
    -
    €{{ attention.outstanding|floatformat:2 }}
    -
    {{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season
    -
    +
    + {% comment %}
    +
    Outstanding
    +
    €{{ attention.outstanding|floatformat:2 }}
    +
    {{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season
    {% endcomment %} -
    -
    -
    {% lucide "user-x" size=16 %} Teams without coach
    -
    {{ attention.teams_without_manager }}
    -
    Teams nobody can pick a squad for
    +
    +
    Teams without coach
    +
    {{ attention.teams_without_manager }}
    +
    Teams nobody can pick a squad for
    +
    + +
    +
    Unrostered members
    +
    {{ attention.unrostered }}
    +
    Active members on no team
    +
    + +
    +
    Pending
    +
    {{ attention.pending_approvals }}
    +
    Memberships awaiting approval
    +
    + +
    +
    New members
    +
    {{ attention.new_members }}
    + {# First season at this club — someone returning after a year away is a renewal. #} +
    First season at this club
    +
    + +
    +
    Renewal rate
    +
    + {% if attention.renewal_rate is None %}N/A{% else %}{{ attention.renewal_rate }}%{% endif %} +
    +
    + {% if attention.renewal_rate is None %} + No previous season + {% else %} + + {% endif %}
    -
    -
    -
    {% lucide "user-minus" size=16 %} Unrostered members
    -
    {{ attention.unrostered }}
    -
    Active members on no team
    +
    +
    Attendance rate
    +
    + {% if attention.attendance.turnout is None %}N/A{% else %}{{ attention.attendance.turnout }}%{% endif %}
    -
    - -
    -
    -
    {% lucide "clock" size=16 %} Pending
    -
    {{ attention.pending_approvals }}
    -
    Memberships awaiting approval
    -
    -
    - -
    -
    -
    {% lucide "sparkles" size=16 %} New members
    -
    {{ attention.new_members }}
    - {# First season at this club — someone returning after a year away is a renewal. #} -
    First season at this club
    -
    -
    - -
    -
    -
    {% lucide "repeat" size=16 %} Renewal rate
    -
    - {% if attention.renewal_rate is None %} - N/A - {% else %} - {{ attention.renewal_rate }}% - {% endif %} -
    -
    - {% if attention.renewal_rate is None %} - No previous season - {% else %} - - {% endif %} -
    -
    -
    - -
    -
    -
    {% lucide "user-check" size=16 %} Attendance rate
    -
    - {% if attention.attendance.turnout is None %} - N/A - {% else %} - {{ attention.attendance.turnout }}% - {% endif %} -
    -
    - {% if attention.attendance.turnout is None %} - No events this season - {% else %} - - {% endif %} -
    +
    + {% if attention.attendance.turnout is None %} + No events this season + {% else %} + + {% endif %}
    + {# --- two-column layout: left = charts/stats, right = flags/admins/billing --- #} +
    +
    +
    +
    +
    {% lucide "user-plus" size=16 %} Signups per month
    +

    New members against returning ones.

    +
    + +
    +
    +
    +
    {% lucide "wallet" size=16 %} Club fee status this season
    +
    + +
    +
    +
    -
    -
    -
    -

    {% lucide "user-plus" size=18 %} Signups per month

    -

    New members against returning ones.

    -
    - -
    +
    + {% for group in groups %} +
    +
    {% lucide group.icon size=16 %} {{ group.title }}
    +
    + {% for label, value in group.stats %} +
    +
    {{ label }}
    +
    {% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}
    +
    + {% endfor %} +
    +
    + {% endfor %}
    + + {% include "controlpanel/_club_home_location_card.html" %}
    -
    -
    -

    {% lucide "wallet" size=18 %} Club fee status this season

    -
    - -
    -
    + +
    + {% include "controlpanel/_club_features_card.html" %} + {% include "controlpanel/_club_admins_card.html" %} + {% include "controlpanel/_club_billing_card.html" %}
    - -
    - {% for group in groups %} -
    -
    -

    {% lucide group.icon size=18 %} {{ group.title }}

    -
    - {% for label, value in group.stats %} -
    -
    {{ label }}
    -
    {% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}
    -
    - {% endfor %} -
    -
    -
    - {% endfor %} -
    - {% include "controlpanel/_club_features_card.html" %} - {% include "controlpanel/_club_billing_card.html" %} - {% include "controlpanel/_club_home_location_card.html" %} - {% include "controlpanel/_club_admins_card.html" %} {% endblock panel %} {% block extra_body %} @@ -205,64 +193,50 @@ {% endblock extra_body %} diff --git a/controlpanel/templates/controlpanel/club_form.html b/controlpanel/templates/controlpanel/club_form.html index 077c0fb..8486fb5 100644 --- a/controlpanel/templates/controlpanel/club_form.html +++ b/controlpanel/templates/controlpanel/club_form.html @@ -1,21 +1,29 @@ {% extends "controlpanel/base.html" %} {% load lucide ui %} -{% block heading %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock heading %} +{% block panel_title %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock panel_title %} + +{% block breadcrumb %} + clubs + / + {% if object %}{{ object.slug }}{% else %}new{% endif %} +{% endblock breadcrumb %} {% block panel %} -
    +
    {% if object %}Edit {{ object }}{% else %}New club{% endif %}
    + +
    -
    + {% csrf_token %} {% for error in form.non_field_errors %} -
    +
    {{ error }}
    {% endfor %} -
    +
    {% form_field form.name %} {% form_field form.legal_name %} {% form_field form.contact_email %} @@ -23,22 +31,22 @@ {% form_field form.sport_type %}
    -
    +
    -
    +
    {% form_field form.logo %} {% form_field form.primary_color %} {% form_field form.secondary_color %}
    -
    +
    -
    +
    {% form_field form.season_start %} {% form_field form.season_duration_months %}
    -
    +
    {% lucide "arrow-left" size=16 %} Cancel
    diff --git a/controlpanel/templates/controlpanel/club_list.html b/controlpanel/templates/controlpanel/club_list.html index a815aa4..a244287 100644 --- a/controlpanel/templates/controlpanel/club_list.html +++ b/controlpanel/templates/controlpanel/club_list.html @@ -1,41 +1,52 @@ {% extends "controlpanel/base.html" %} {% load lucide %} -{% block heading %}{% if show_archived %}Archived clubs{% else %}Clubs{% endif %}{% endblock heading %} +{% block panel_title %}{% if show_archived %}Archived clubs{% else %}Clubs{% endif %}{% endblock panel_title %} + +{% block breadcrumb %} + clubs + {% if show_archived %} + | + Archived + {% endif %} + | + {{ clubs|length }} club{{ clubs|length|pluralize }} +{% endblock breadcrumb %} {% block actions %} {% if show_archived %} - {% lucide "archive-x" size=16 %} Hide archived clubs + {% lucide "archive-x" size=14 %} Hide archived {% else %} - {% lucide "archive" size=16 %} Show archived clubs + {% lucide "archive" size=14 %} Show archived {% endif %} - {% lucide "plus" size=16 %} New club + {% lucide "plus" size=14 %} New club {% endblock actions %} {% block panel %} - - {% if show_archived %}{% endif %} - + {% if show_archived %} +
    + Archived clubs + {% lucide "archive" size=12 %} {{ clubs|length }} archived +
    + {% endif %} - + + {% if show_archived %}{% endif %} +
    + {% lucide "search" size=14 %} + +
    + {% if search %} - {% lucide "x" size=16 %} Clear filter + {% lucide "x" size=14 %} Clear {% endif %} -
    -
    - {% if show_archived %} - {% include "controlpanel/_club_health_table.html" with empty_message="No archived clubs." %} - {% else %} - {% include "controlpanel/_club_health_table.html" %} - {% endif %} -
    + +
    + {% if show_archived %} + {% include "controlpanel/_club_health_table.html" with empty_message="No archived clubs." %} + {% else %} + {% include "controlpanel/_club_health_table.html" %} + {% endif %}
    {% endblock panel %} diff --git a/controlpanel/templates/controlpanel/dashboard.html b/controlpanel/templates/controlpanel/dashboard.html index 8dd7260..569c269 100644 --- a/controlpanel/templates/controlpanel/dashboard.html +++ b/controlpanel/templates/controlpanel/dashboard.html @@ -1,111 +1,213 @@ {% extends "controlpanel/base.html" %} {% load static lucide %} -{% block heading %}RosterChief Platform Dashboard{% endblock heading %} -{% block subheading %}Welcome back {{ user.member.first_name }} · {% now "d b Y" %}{% endblock subheading %} +{% block panel_title %}Platform health{% endblock panel_title %} + +{% block breadcrumb %} + platform + | + {{ totals.clubs }} club{{ totals.clubs|pluralize }} live + | + {{ totals.members }} member{{ totals.members|pluralize }} +{% endblock breadcrumb %} + +{% block strip_right %} + {% if failed_jobs %} + {{ failed_jobs|length }} job failure{{ failed_jobs|length|pluralize }} · 24h + {% endif %} +{% endblock strip_right %} {% block actions %} - {% lucide "plus" size=16 %} Create new club + {% lucide "plus" size=14 %} Create club {% endblock actions %} {% block panel %} -
    -
    -
    -
    {% lucide "building-2" size=16 %} Clubs
    -
    {{ totals.clubs }}
    -
    Managing {{ totals.members }} member{{ totals.members|pluralize }}
    -
    + {# --- KPI row ------------------------------------------------------- #} +
    +
    +
    clubs live
    +
    {{ totals.clubs }}
    +
    {{ totals.archived_clubs }} archived
    - -
    -
    -
    {% lucide "archive" size=16 %} Archived clubs
    -
    {{ totals.archived_clubs }}
    -
    Not accessible but data maintained
    -
    +
    +
    members
    +
    {{ totals.members }}
    +
    {{ totals.admins }} club admin{{ totals.admins|pluralize }}
    - -
    -
    -
    {% lucide "calendar-x" size=16 %} No current season
    -
    {{ attention.clubs_without_season }}
    -
    Clubs that cannot take signups
    -
    +
    +
    dues owed
    +
    €{{ attention.dues_owed|floatformat:0 }}
    +
    {{ attention.dues_in_grace }} in grace · {{ attention.dues_overdue }} overdue
    - -
    -
    -
    {% lucide "moon-star" size=16 %} Dormant clubs
    -
    {{ attention.dormant_clubs }}
    -
    No events scheduled next 30 days
    -
    +
    +
    no season
    +
    {{ attention.clubs_without_season }}
    +
    can't take signups
    - -
    -
    -
    {% lucide "shield-alert" size=16 %} MFA pending
    -
    {{ attention.admins_pending_mfa }}
    -
    Admins without MFA configured
    -
    +
    +
    dormant clubs
    +
    {{ attention.dormant_clubs }}
    +
    no events 30d
    - -
    -
    -
    {% lucide "receipt-euro" size=16 %} Payment pending
    -
    €{{ attention.dues_owed|floatformat:2 }}
    -
    - {{ attention.dues_in_grace }} in grace · - {{ attention.dues_overdue }} overdue - {% comment %} - Renewals pending should sit at ~0: the cron job renews clubs 30 days out and - then they fall past the horizon. A number that lingers here means the job has - stopped and a club is about to use the platform for free — which no other - figure on this page reveals, because nothing has been billed yet. - {% endcomment %} - {% if attention.renewals_pending %} - · {{ attention.renewals_pending }} awaiting renewal - {% endif %} -
    -
    +
    +
    failed jobs
    +
    {{ failed_jobs|length }}
    +
    last 24h · jobs
    -
    -
    -
    -

    {% lucide "user-plus" size=18 %} Signups per month

    -

    New members against returning ones, across every club.

    -
    +
    + {# --- left column ------------------------------------------------ #} +
    +
    +
    + {# No hand-written colour key here: Chart.js renders its own real, correctly-swatched legend below the chart -- a second, uncoloured text hint duplicating it just reads as broken. #} + Signups · {{ charts.signups|length }} months +
    +
    -
    -
    -
    -

    {% lucide "milestone" size=18 %} Onboarding

    -

    Tracking club onboarding to ensure a smooth start

    -
    +
    +
    + Club health + + sorted by risk +
    + + + + + + + + + + + + {% for club in clubs %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    ClubActive membersEvents 30dPlanRisk
    {{ club.name }}{{ club.active_members }}{{ club.upcoming_events }}{{ club.plan_name|default:"—" }} + {% if club.risk == "high" %} + high + {% elif club.risk == "watch" %} + watch + {% else %} + ok + {% endif %} +
    No clubs yet.
    +
    + +
    +
    Onboarding
    +
    {% for step in funnel %}
    -
    - {% lucide step.icon size=14 %} {{ step.label }} - {{ step.count }} -
    - +
    {% lucide step.icon size=12 %} {{ step.label }}
    +
    {{ step.count }}
    +
    {% endfor %}
    -
    -
    -
    -

    {% lucide "building-2" size=18 %} Clubs

    - {% include "controlpanel/_club_health_table.html" %} + {# --- right column ------------------------------------------------ #} +
    +
    +
    Alerts
    +
    + {% if attention.renewals_pending %} +
    +
    {{ attention.renewals_pending }} subscription{{ attention.renewals_pending|pluralize }} awaiting renewal
    +
    renew_subscriptions may be stalled
    +
    + {% endif %} + {% if attention.dues_overdue %} +
    +
    {{ attention.dues_overdue }} club{{ attention.dues_overdue|pluralize }} overdue on platform fees
    +
    past grace — see billing
    +
    + {% endif %} + {% for run in job_log %} + {% if run.status == "failure" %} +
    +
    {{ run.name }} failed
    +
    {{ run.started_at|date:"H:i" }} · {{ run.error|truncatechars:60|default:"see the Jobs tab" }}
    +
    + {% endif %} + {% endfor %} + {% if attention.admins_pending_mfa %} +
    +
    {{ attention.admins_pending_mfa }} admin{{ attention.admins_pending_mfa|pluralize }} without MFA
    +
    locked out until they enrol
    +
    + {% endif %} + {% if attention.clubs_unbilled %} +
    +
    {{ attention.clubs_unbilled }} active club{{ attention.clubs_unbilled|pluralize }} on no plan
    +
    using the platform for free
    +
    + {% endif %} + {% if not attention.renewals_pending and not attention.dues_overdue and not attention.admins_pending_mfa and not attention.clubs_unbilled and not failed_jobs %} +
    Nothing needs attention.
    + {% endif %} +
    +
    + +
    +
    Feature adoption
    +
    + {% for flag in flags %} +
    +
    + {{ flag.name }} + {% if flag.overridden %}{{ flag.everyone|yesno:"everyone,off" }}{% else %}{{ flag.clubs }}/{{ totals.clubs }}{% endif %} +
    +
    +
    +
    +
    + {% empty %} +
    No feature flags yet.
    + {% endfor %} +
    +
    + +
    +
    Job log
    +
    + {% for run in job_log %} +
    + {{ run.started_at|date:"H:i" }} + {% if run.status == "success" %} + ok + {% elif run.status == "failure" %} + err + {% else %} + ··· + {% endif %} + {{ run.name }}{% if run.detail %} · {{ run.detail|truncatechars:40 }}{% endif %} +
    + {% empty %} +
    No job runs recorded yet.
    + {% endfor %} +
    + View all jobs → +
    {% endblock panel %} @@ -116,84 +218,28 @@ {% endblock extra_body %} diff --git a/controlpanel/templates/controlpanel/features.html b/controlpanel/templates/controlpanel/features.html index 64fa957..6642a7c 100644 --- a/controlpanel/templates/controlpanel/features.html +++ b/controlpanel/templates/controlpanel/features.html @@ -1,7 +1,15 @@ {% extends "controlpanel/base.html" %} {% load lucide ui %} -{% block heading %}Features{% endblock heading %} +{% block panel_title %}Features{% endblock panel_title %} + +{% block breadcrumb %} + features + | + {{ flags|length }} flag{{ flags|length|pluralize }} + | + {{ switches|length }} switch{{ switches|length|pluralize }} +{% endblock breadcrumb %} {% block actions %} @@ -14,132 +22,153 @@ {% comment %} The lock-down. Clubs get a maintenance page, the scheduled jobs stand down, and the control panel and the auth screens stay open — otherwise you could not sign in to - turn it back off. + turn it back off. This is the single most safety-critical control in the panel, so + the active state gets the full club/club-dark treatment, not just a badge. {% endcomment %} -
    -
    -
    -
    -

    {% lucide "wrench" size=18 %} Maintenance mode

    - {% if maintenance.is_active %} -
    - {% lucide "lock" size=12 %} Platform closed -
    ·
    -
    since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}
    -
    - - {% if maintenance.message %} -
    -
    Message
    -
    {{ maintenance.message }}
    -
    - {% endif %} - {% else %} -

    - Closes every club subdomain and stands the scheduled jobs down. The control panel and the sign-in screens stay open. -

    - {% endif %} -
    +
    + {% if maintenance.is_active %} +
    + {% lucide "lock" size=18 class="shrink-0 text-white" %} + Maintenance mode — platform closed + + since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} · {{ maintenance.started_by.email }}{% endif %}
    + {% else %} +
    + {% lucide "wrench" size=18 class="shrink-0 text-muted" %} + Maintenance mode + + + + platform open + +
    + {% endif %} -
    - {% csrf_token %} - {% if not maintenance.is_active %} -
    - - {{ maintenance_form.message|daisy }} - {{ maintenance_form.message.help_text }} +
    + {% if maintenance.is_active %} + {% if maintenance.message %} +
    +
    Message shown to clubs
    +
    {{ maintenance.message }}
    - - {% else %} - {% endif %} - +

    Every club subdomain is serving a maintenance page and the scheduled jobs have stood down. The control panel and the sign-in screens stay open.

    + +
    + {% csrf_token %} + +
    + {% else %} +

    Closes every club subdomain and stands the scheduled jobs down. The control panel and the sign-in screens stay open.

    + +
    + {% csrf_token %} +
    + + {{ maintenance_form.message|daisy }} + {{ maintenance_form.message.help_text }} +
    + +
    + {% endif %}
    -
    -
    -

    {% lucide "flag" size=18 %} Flags

    -

    - Flags are turned on per club. Setting Everyone to Yes or No overrides club targeting entirely. -

    -
    - - +
    +
    + {% lucide "flag" size=16 class="shrink-0 text-muted" %} + Flags + + {{ flags|length }} flag{{ flags|length|pluralize }} +
    +

    + Flags are turned on per club. Setting Everyone to Yes or No overrides club targeting entirely. +

    +
    +
    + + + + + + + + + + + {% for flag in flags %} - - - - - + + + + + - - - {% for flag in flags %} - - - - - - - - {% empty %} - - - - {% endfor %} - -
    NameEveryoneClubsNote
    NameEveryoneClubsNote{{ flag.name }} + {% if flag.everyone is True %} + On for all + {% elif flag.everyone is False %} + Off everywhere + {% else %} + Per club + {% endif %} + {{ flag.clubs.count }}{{ flag.note|default:"—" }} + +
    {{ flag.name }} - {% if flag.everyone is True %} - On for all - {% elif flag.everyone is False %} - Off everywhere - {% else %} - Per club - {% endif %} - {{ flag.clubs.count }}{{ flag.note|default:"-" }} - -
    No features yet.
    -
    - - {% comment %} Dialogs live outside the table: may only contain elements. {% endcomment %} - {% for flag in flags %} - {% url 'controlpanel:flag_update' flag.pk as flag_update_url %} - {% include "controlpanel/_modal_form.html" with modal_id=flag.pk|dom_id:"flag_edit_modal" title="Edit "|add:flag.name form=flag.edit_form action_url=flag_update_url submit_label="Save" submit_icon="check" %} - {% endfor %} + {% empty %} + + No features yet. + + {% endfor %} + +
    + + {% comment %} Dialogs live outside the table: may only contain elements. {% endcomment %} + {% for flag in flags %} + {% url 'controlpanel:flag_update' flag.pk as flag_update_url %} + {% include "controlpanel/_modal_form.html" with modal_id=flag.pk|dom_id:"flag_edit_modal" title="Edit "|add:flag.name form=flag.edit_form action_url=flag_update_url submit_label="Save" submit_icon="check" %} + {% endfor %}
    -
    -
    -

    {% lucide "power" size=18 %} Switches

    -

    Global on/off for the whole platform — kill-switches, maintenance, infra rollouts.

    -
    - - - {% for switch in switches %} - - - - - - {% empty %} - - - - {% endfor %} - -
    {{ switch.name }}{{ switch.note|default:"-" }} -
    - {% csrf_token %} - -
    -
    No switches yet — add one in the Django admin.
    -
    + +
    +
    + {% lucide "power" size=16 class="shrink-0 text-muted" %} + Switches + + {{ switches|length }} switch{{ switches|length|pluralize }} +
    +

    Global on/off for the whole platform — kill-switches, maintenance, infra rollouts.

    +
    + + + + + + + + + + {% for switch in switches %} + + + + + + {% empty %} + + + + {% endfor %} + +
    NameNote
    {{ switch.name }}{{ switch.note|default:"—" }} +
    + {% csrf_token %} + +
    +
    No switches yet — add one in the Django admin.
    {% endblock panel %} diff --git a/controlpanel/templates/controlpanel/jobs.html b/controlpanel/templates/controlpanel/jobs.html new file mode 100644 index 0000000..beb0dfc --- /dev/null +++ b/controlpanel/templates/controlpanel/jobs.html @@ -0,0 +1,77 @@ +{% extends "controlpanel/base.html" %} +{% load lucide %} + +{% block panel_title %}Jobs{% endblock panel_title %} + +{% block breadcrumb %} + jobs + | + {{ jobs|length }} scheduled +{% endblock breadcrumb %} + +{% block panel %} +
    + {% lucide "info" size=16 class="inline -mt-0.5 mr-1" %} + These run on Celery Beat's own schedule (see rosterchief/settings.py) — this page is monitoring only, there is no "run now" here. A run that never appears at its scheduled time is the signal something's wrong with worker/beat, not with this page. +
    + +
    + {% for job in jobs %} +
    +
    +
    +
    {{ job.label }}
    +
    {{ job.name }}
    +
    + {% if job.latest %} + {% if job.latest.status == "success" %} + ok + {% elif job.latest.status == "failure" %} + error + {% else %} + running + {% endif %} + {% else %} + never run + {% endif %} +
    +
    + {# flex-1 here so the paragraph absorbs whatever space a shorter description leaves, keeping the schedule pill pinned to the bottom of the content block across every card in the row instead of drifting with description length. #} +

    {{ job.description }}

    +
    + {% lucide "clock" size=12 %} {{ job.schedule }} +
    + {% if job.latest %} +
    + last run {{ job.latest.started_at|date:"j M Y, H:i" }} + {% if job.latest.duration %}· {{ job.latest.duration.total_seconds|floatformat:1 }}s{% endif %} +
    + {% if job.latest.status == "success" and job.latest.detail %} +
    {{ job.latest.detail }}
    + {% elif job.latest.status == "failure" and job.latest.error %} +
    {{ job.latest.error }}
    + {% endif %} + {% endif %} +
    + + {% if job.runs|length > 1 %} +
    +
    Recent runs
    + {% for run in job.runs|slice:":5" %} +
    + {{ run.started_at|date:"d/m H:i" }} + {% if run.status == "success" %} + ok + {% elif run.status == "failure" %} + error + {% else %} + running + {% endif %} +
    + {% endfor %} +
    + {% endif %} +
    + {% endfor %} +
    +{% endblock panel %} diff --git a/controlpanel/templates/controlpanel/plan_delete.html b/controlpanel/templates/controlpanel/plan_delete.html index 69dd9d1..f4e9442 100644 --- a/controlpanel/templates/controlpanel/plan_delete.html +++ b/controlpanel/templates/controlpanel/plan_delete.html @@ -7,76 +7,96 @@ that list can be long. See billing.services.plans for what "delete" actually does. {% endcomment %} -{% block heading %}{% blocktrans with plan=plan.name %}Delete “{{ plan }}”{% endblocktrans %}{% endblock heading %} +{% block panel_title %}{% blocktrans with plan=plan.name %}Delete “{{ plan }}”{% endblocktrans %}{% endblock panel_title %} + +{% block breadcrumb %} + billing + | + delete plan + | + {{ plan.name }} +{% endblock breadcrumb %} {% block actions %} {% lucide "arrow-left" size=16 %} {% trans "Back to billing" %} {% endblock actions %} {% block panel %} -
    -
    -

    {% lucide "triangle-alert" size=18 %} {% trans "This can't be undone" %}

    - {% if impact.will_hard_delete %} -

    {% blocktrans with plan=plan.name %}“{{ plan }}” has never billed anyone, so it will be removed completely.{% endblocktrans %}

    - {% else %} -

    {% blocktrans with plan=plan.name %}“{{ plan }}” has billing history, so it will be hidden rather than removed — past invoices will still show what they were billed under.{% endblocktrans %}

    - {% endif %} -
    -
    - - {% if impact.unsubscribed_clubs %} -
    -
    -

    - {% lucide "building-2" size=18 %} - {% blocktrans count counter=impact.unsubscribed_clubs|length %}{{ counter }} club is currently on this plan{% plural %}{{ counter }} clubs are currently on this plan{% endblocktrans %} -

    -

    {% trans "Deleting this plan removes their subscription outright — each shows as not billed for anything afterwards, the same as a club that was never put on a plan." %}

    -
      - {% for club in impact.unsubscribed_clubs %} -
    • - {{ club.name }} - {% lucide "circle-x" size=12 %} {% trans "Loses this plan" %} -
    • - {% endfor %} -
    +
    + {# --- the plan being removed, and which of the two outcomes applies -- #} +
    + {% lucide "triangle-alert" size=20 class="mt-0.5 shrink-0 text-club-dark" %} +
    +
    {% trans "This can't be undone" %}
    + {% if impact.will_hard_delete %} +

    {% blocktrans with plan=plan.name %}“{{ plan }}” has never billed anyone, so the plan will be removed completely.{% endblocktrans %}

    + {% else %} +

    {% blocktrans with plan=plan.name %}“{{ plan }}” has billing history, so it will be hidden rather than removed — past invoices will still show what they were billed under.{% endblocktrans %}

    + {% endif %}
    - {% endif %} - {% if impact.broken_trial_clubs %} -
    -
    -

    - {% lucide "hourglass" size=18 %} - {% blocktrans count counter=impact.broken_trial_clubs|length %}{{ counter }} club's trial is scheduled to switch to this plan{% plural %}{{ counter }} clubs' trials are scheduled to switch to this plan{% endblocktrans %} -

    -

    {% trans "They stay on their current trial plan, but the scheduled switch is cancelled — pick a new plan for them before the trial ends." %}

    -
      - {% for club in impact.broken_trial_clubs %} -
    • - {{ club.name }} - {% lucide "octagon-alert" size=12 %} {% trans "Trial needs a new plan" %} -
    • - {% endfor %} -
    + {# --- clubs currently subscribed to this plan -------------------------- #} + {% if impact.unsubscribed_clubs %} +
    +
    + {% lucide "building-2" size=16 class="text-club-dark" %} + + {% blocktrans count counter=impact.unsubscribed_clubs|length %}{{ counter }} club is currently on this plan{% plural %}{{ counter }} clubs are currently on this plan{% endblocktrans %} + +
    +

    {% trans "Deleting this plan removes their subscription outright — each shows as not billed for anything afterwards, the same as a club that was never put on a plan." %}

    + + + {% for club in impact.unsubscribed_clubs %} + + + + + {% endfor %} + +
    {{ club.name }} + {% lucide "circle-x" size=11 %} {% trans "Loses this plan" %} +
    -
    - {% endif %} + {% endif %} - {% if not impact.has_impact %} -
    - {% lucide "info" size=20 %} - {% trans "No club is currently on this plan, or has a trial scheduled to switch to it." %} -
    - {% endif %} + {# --- clubs mid-trial, scheduled to land on this plan ------------------- #} + {% if impact.broken_trial_clubs %} +
    +
    + {% lucide "hourglass" size=16 class="text-warn-text" %} + + {% blocktrans count counter=impact.broken_trial_clubs|length %}{{ counter }} club's trial is scheduled to switch to this plan{% plural %}{{ counter }} clubs' trials are scheduled to switch to this plan{% endblocktrans %} + +
    +

    {% trans "They stay on their current trial plan, but the scheduled switch is cancelled — pick a new plan for them before the trial ends." %}

    + + + {% for club in impact.broken_trial_clubs %} + + + + + {% endfor %} + +
    {{ club.name }} + {% lucide "octagon-alert" size=11 %} {% trans "Trial needs a new plan" %} +
    +
    + {% endif %} -
    - {% csrf_token %} -
    + {% if not impact.has_impact %} +
    + {% lucide "info" size=18 %} + {% trans "No club is currently on this plan, or has a trial scheduled to switch to it." %} +
    + {% endif %} + + + {% csrf_token %} {% lucide "x" size=16 %} {% trans "Cancel" %} -
    -
    + +
    {% endblock panel %} diff --git a/controlpanel/templates/templatetags/field.html b/controlpanel/templates/templatetags/field.html index 5c1494b..3e94398 100644 --- a/controlpanel/templates/templatetags/field.html +++ b/controlpanel/templates/templatetags/field.html @@ -37,13 +37,13 @@ /> {% elif field_type == "checkbox" %} -
    +
    Club archived
    ', html=False) + self.assertContains(response, "alert alert-warning border-warning") + self.assertContains(response, "Club archived") self.assertContains(response, "{column}") diff --git a/controlpanel/urls.py b/controlpanel/urls.py index b4109d9..1415d4c 100644 --- a/controlpanel/urls.py +++ b/controlpanel/urls.py @@ -40,4 +40,6 @@ urlpatterns = [ path("admins/add/", views.PlatformAdminAddView.as_view(), name="admin_add"), path("admins//update/", views.PlatformAdminUpdateView.as_view(), name="admin_update"), path("admins//revoke/", views.PlatformAdminRevokeView.as_view(), name="admin_revoke"), + # Jobs (Celery Beat's scheduled platform jobs -- monitoring only, see controlpanel/services/jobs.py) + path("jobs/", views.JobsView.as_view(), name="jobs"), ] diff --git a/controlpanel/views.py b/controlpanel/views.py index c2ba039..7f32131 100644 --- a/controlpanel/views.py +++ b/controlpanel/views.py @@ -25,6 +25,7 @@ from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, HomeLocati from .messages import notify from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin from .services.admins import grant_club_admin, revoke_club_admin +from .services.jobs import job_overview, recent_job_runs from .services.platform_admins import ( PlatformAdminError, grant_platform_access, @@ -32,7 +33,7 @@ from .services.platform_admins import ( revoke_platform_access, set_platform_access, ) -from .services.statistics import club_attention, club_charts, club_statistics, clubs_with_health, flag_adoption, flags_for_club, onboarding_funnel, platform_attention, platform_charts, platform_totals +from .services.statistics import club_attention, club_charts, club_statistics, clubs_by_risk, clubs_with_health, flag_adoption, flags_for_club, onboarding_funnel, platform_attention, platform_charts, platform_totals Flag = get_waffle_flag_model() Switch = get_waffle_switch_model() @@ -63,12 +64,28 @@ class DashboardView(PlatformStaffRequiredMixin, TemplateView): funnel=onboarding_funnel(), flags=flag_adoption(), charts=platform_charts(), - clubs=clubs_with_health(), + clubs=clubs_by_risk(), + # failed_jobs itself comes from controlpanel.context_processors.job_health, on + # every controlpanel page (the command bar's status indicator needs it too) -- + # not re-fetched here, so the query only runs once per request. + job_log=recent_job_runs(), today=timezone.localdate(), **kwargs, ) +class JobsView(PlatformStaffRequiredMixin, TemplateView): + """Status and recent history of the scheduled platform jobs -- see features/jobs.py for + the registry and features/models.JobRun for what a Celery task run writes. Monitoring + only, deliberately: these run on Celery Beat's own schedule (rosterchief/settings.py), + not on demand from here.""" + + template_name = "controlpanel/jobs.html" + + def get_context_data(self, **kwargs): + return super().get_context_data(nav="jobs", jobs=job_overview(), **kwargs) + + class ClubListView(PlatformStaffRequiredMixin, ListView): template_name = "controlpanel/club_list.html" context_object_name = "clubs" diff --git a/design_handoff_rosterchief_platform/README.md b/design_handoff_rosterchief_platform/README.md new file mode 100644 index 0000000..758e1c2 --- /dev/null +++ b/design_handoff_rosterchief_platform/README.md @@ -0,0 +1,278 @@ +# Handoff: RosterChief Platform — four surfaces, one system + +## Overview + +RosterChief is a sports club management platform (built for ice hockey clubs; Sharks Mechelen is the reference tenant). This handoff covers a complete visual and structural redesign of all four surfaces: + +| # | Surface | Audience | Device | +|---|---------|----------|--------| +| 01 | **Control panel** | RosterChief staff (3–4 people) | Desktop only | +| 02 | **Club management** | Board, secretary, treasurer | Desktop only | +| 03 | **Coach mode** | Anyone with a staff role on a team | Mobile first | +| 04 | **Member mode** | Every member and every parent | Mobile first | + +**The single most important structural decision:** surfaces 03 and 04 are *two modes of one installed app*, not two apps. A persistent Coach / Member switcher sits in the app header; it only appears for people who hold a staff role, and the chosen mode is remembered per device. Each mode has its own tab bar and its own navigation stack. + +**The second:** there is no "parent app". A person is one account with a set of memberships and roles. Katrien Somers is simultaneously a Div 4 player, the mother of two U16 players, and head coach of U16 — three facts about one row, not three logins. Every per-member screen (attendance, profile, dues) carries a **person switcher** at the top listing the people that account manages, including "me". + +## About the design files + +The files in this bundle are **design references created in HTML** — prototypes showing intended look and behaviour, not production code to copy. `RosterChief Platform.dc.html` is a single-file design document containing all 25 screens laid out side by side on a canvas. It uses inline styles and a small custom runtime; **do not port that runtime.** + +The task is to **recreate these designs in the RosterChief codebase** (`bsiebens/RosterChief` — Django, server-rendered templates) using its established patterns. Per the user's preference, **use Tailwind CSS** for styling: the token table below is written as a `tailwind.config` extension, and every measurement in this document maps onto a Tailwind utility. + +The Django app boundaries the screens map onto are listed in `github.md` at the project root (`## Screen map`). + +## Fidelity + +**High-fidelity.** Final colours, typography, spacing and interaction affordances. Recreate pixel-accurately. Two deliberate exceptions: + +1. **Photography is placeholder.** Every `` in the reference marks a spot where real club photography belongs (hero action shots, team photos, news covers, article portraits). Sizes and gradient scrims are final; the images are not. +2. **The iOS bezel is presentation only.** `ios-frame.jsx` draws a device frame so the mobile screens read as phone screens in the design document. The app content starts *below* a 54px status-bar inset and ends above a 26–30px home-indicator inset — preserve those safe areas via `env(safe-area-inset-*)`, not fixed padding. + +--- + +## Design tokens + +### Tailwind config + +```js +// tailwind.config.js +export default { + theme: { + extend: { + colors: { + ink: '#0B1220', // darkest — app chrome, sidebars, dark cards + navy: '#101E36', // member-mode app header, management sidebar accents + steel: '#1B2B47', // inset controls on dark (switcher track, chips) + hairline: '#1E2B42', // rules on dark surfaces + paper: '#F4F5F7', // app/page background + line: '#E3E6EB', // 1px borders on light + rule: '#EEF0F3', // table row dividers + edge: '#D6DAE1', // stronger light border (control panel, inputs) + stroke: '#C9CFD8', // secondary-button border + muted: '#6C7787', // secondary text + dim: '#8B95A4', // tertiary text, inactive tab icons + slate: '#3A4658', // body copy on light + onDark: '#93A0B4', // secondary text on dark + onDarkDim: '#7C8AA0', + onDarkFaint: '#5C6B85', + club: '#E4002B', // CLUB ACCENT — themeable, see "Club theming" + clubDark: '#B00021', // club accent, text-on-light / hover + ice: '#14B8E8', // coach-mode accent + iceInk: '#04212C', // text on ice + ok: '#14A05A', + okBg: '#E6F6EE', okBorder: '#BFE7D3', okText: '#0C7A43', + warn: '#F0A22E', + warnBg: '#FFF5E4', warnBorder: '#F6E0B8', warnText: '#9A6410', warnDeep: '#7A4E08', + dangerBg: '#FDECEC', dangerBorder: '#F5C9CE', + infoBg: '#EAF7FC', infoBorder: '#C3E7F4', infoText: '#0A6F91', + rowSel: '#FFF7F8', // selected table row (club tint) + rowFocus: '#F4F9FF', // focused/active row (info tint) + rowWarn: '#FFFDF6', // row needing attention + subhead: '#F8F9FA', // table header / section header fill + violet: '#7C5CFC', // calendar resource: training rink + }, + fontFamily: { + display: ['"Barlow Condensed"', 'sans-serif'], + sans: ['Barlow', 'system-ui', 'sans-serif'], + mono: ['"IBM Plex Mono"', 'monospace'], + }, + }, + }, +} +``` + +Google Fonts: `Barlow:400,500,600,700` · `Barlow+Condensed:600,700,800` · `IBM+Plex+Mono:400,500,600`. + +### Type roles + +| Role | Spec | Tailwind | +|---|---|---| +| Screen title (mobile) | Barlow Condensed 800, 22–26px, uppercase | `font-display font-extrabold text-2xl uppercase` | +| Screen title (desktop) | Barlow Condensed 800, 24px, uppercase | `font-display font-extrabold text-2xl uppercase` | +| Section headline (doc) | Barlow Condensed 800, 56px, `leading-[.95]`, uppercase | `font-display font-extrabold text-[56px] leading-[.95] uppercase` | +| Hero headline (mobile) | Barlow Condensed 800, 28–40px, `leading-[.96]`, uppercase | | +| Card title | Barlow Condensed 800, 20–24px, uppercase | | +| Eyebrow / label | Barlow Condensed 700–800, 11–12px, `tracking-[.14em]`, uppercase, `text-muted` | `font-display font-extrabold text-xs tracking-[.14em] uppercase text-muted` | +| Nav / button label | Barlow Condensed 800, 13–17px, `tracking-[.1em]`, uppercase | | +| Body | Barlow 400, 16px, `leading-[1.6]`, `text-slate` | | +| Lede | Barlow 600, 18px, `leading-[1.5]`, `text-ink` | | +| Row title | Barlow 600, 15px, `text-ink` | | +| Row meta | Barlow 400, 12–13px, `text-muted` | | +| **Scoreboard numeral** | Barlow Condensed 800, 26–76px, `leading-none`, `tabular-nums` | `font-display font-extrabold tabular-nums leading-none` | +| **Jersey number** | Barlow Condensed 800, 18–26px, `tabular-nums` | | +| Data / mono | IBM Plex Mono 400, 10–13px — IDs, money, timestamps, licence numbers, technical values | `font-mono` | + +Rules: display type is **always uppercase**; body copy never is. Money always mono, always European format (`€ 780,00`). Dates in UI copy are `Sat 22 Aug`; dates in mono fields are ISO-ish (`2026-08-01`, `11.03.14-234.56`). + +### Spacing, radius, borders + +- Spacing: 4px base. Common: `gap-1.5 gap-2 gap-2.5 gap-3 gap-3.5 gap-4 gap-5`; mobile screen padding `px-4`; desktop content padding `px-7 py-6`; card padding `p-4` (mobile) / `p-[18px]` (desktop). +- Radius: mobile cards `rounded-[14px]`; nested/inner cards `rounded-xl`; desktop cards `rounded-xl`; buttons and inputs `rounded-lg`; chips/pills `rounded-full`; **control panel `rounded` (4px) — square by intent**; desktop frame `rounded-xl`. +- Borders: `border border-line` on light cards; `border-edge` in the control panel; `border-[1.5px] border-stroke` on secondary buttons; dividers `border-rule`. +- Shadows: only on the doc-level frames (`shadow-[0_24px_60px_rgba(11,18,32,.18)]`) and the member-detail drawer (`shadow-[-24px_0_60px_rgba(11,18,32,.2)]`). **Cards inside the product carry no shadow** — separation comes from hairlines. +- Minimum hit target: **44px** everywhere on mobile. Attendance in/out buttons are 44–46px tall; primary mobile CTAs 46–52px. + +### Components + +**Buttons** — height 46px mobile / 34–36px desktop, `rounded-lg`, label in Barlow Condensed 800 uppercase `tracking-[.1em]`: +- Primary: `bg-club text-white` +- Dark: `bg-ink text-white` +- Coach primary: `bg-ice text-iceInk` +- Positive (attendance In): `bg-ok text-white` +- Secondary: `bg-white border-[1.5px] border-stroke text-ink` +- Ghost on dark: `bg-white/15 text-white` + +**Status pills** — `rounded-full px-2.5 py-[5px]`, Barlow Condensed 700, 11–13px, `tracking-[.1em]`, uppercase: +| State | Classes | +|---|---| +| In / Paid / Ready / Clean / Live | `bg-okBg text-okText border border-okBorder` | +| Out / Overdue / Transfer | `bg-dangerBg text-clubDark border border-dangerBorder` | +| No reply / Due / Watch / Medical | `bg-warnBg text-warnText border border-warnBorder` | +| Selected / Beta / Scheduled | `bg-infoBg text-infoText border border-infoBorder` | +| Draft / Optional | `bg-rule text-[#4A5566] border border-[#DDE1E7]` | + +**Role switcher** — full-width segmented pill inside the app header. Track `bg-steel rounded-full p-1 gap-1` (member mode) or `bg-ink/`+`bg-steel` (coach mode); each segment `flex-1 h-9 rounded-full`; active segment is `bg-white text-ink` in Member mode and `bg-ice text-iceInk` in Coach mode; inactive `text-onDark`. + +**Toggle** — `w-12 h-7 rounded-full` (mobile) / `w-10 h-[22px]` (desktop); on `bg-ok`, off `bg-edge`, beta `bg-ice`; knob is a white circle inset 3px. + +**Bottom tab bar** — `bg-white border-t border-line pt-2 pb-[26px]` (member) or `bg-ink pt-2 pb-[26px]` (coach); four equal items, 48px tall, 21px stroke-2 icon over a Barlow Condensed 700 12px `tracking-[.08em]` uppercase label. Active colour = `club` (member) / `ice` (coach); inactive `dim` / `#6E7C93`. +- Member tabs: Home · Calendar · News · Me +- Coach tabs: Today · Squad · Schedule · Create + +**Table row (desktop)** — CSS grid, 40px header row (`bg-subhead border-b border-line`, mono or Barlow Condensed 12px `tracking-[.12em]` uppercase `text-muted` headings), 46–52px body rows divided by `border-b border-rule`. Selected rows tint `rowSel`, focused row `rowFocus`, attention row `rowWarn`. Bulk-action bar appears as a **48px `bg-ink` strip directly above the table** with the count in Barlow Condensed uppercase and actions in `text-ice`. + +**Club crest mark** — the shield is a clip-path, not an image: `clip-path: polygon(50% 0, 100% 18%, 100% 62%, 50% 100%, 0 62%, 0 18%)` on a solid block. Sizes: 20×22 (preview), 28×30 (sidebar), 30×32 (app header), 64×68 (control panel club header). Replace with the club's real SVG logo where one is uploaded; the clip-path is the fallback. + +--- + +## Club theming + +Club customisation is **club-level only**: primary colour, secondary colour, logo, wordmark. Model fields already exist (`club/models.py`: `Club.primary_color`, `Club.secondary_color`, `Club.logo`). + +- `secondary_color` drives the **club accent** (`--club`): app header active tab, primary buttons, section eyebrows, selected-row tint, public-site nav and join CTA, invoice/email headers. +- `primary_color` drives dark chrome where the club overrides the platform navy. +- **Never themeable:** status colours, type, spacing, neutrals, table chrome, form fields. This is what keeps every club legible and every screen familiar. Implement as CSS custom properties set on `:root` per tenant, consumed by Tailwind arbitrary values (`bg-[var(--club)]`) or a `club` colour mapped to `var(--club)`. +- Coach mode uses `ice` (#14B8E8) regardless of club — the mode signal must survive theming. +- The control panel is **never** club-branded. +- Escape hatch: a per-club custom stylesheet for the public site only (`custom_stylesheet` feature flag). + +--- + +## Screens + +### Member mode (mobile, 402×874 reference) + +**M1 · Home.** Navy header: crest + club name + season, bell with unread dot, role switcher below. Body scrolls: person switcher chips (avatar + first name; active chip `border-[1.5px] border-ink`, plus a "Me" chip) → dark hero card with 120px photo, gradient scrim, `NEXT UP · SAT 22 AUG` eyebrow in `ice`, match title, meta row (face-off / meet / venue), then "Lars — are you in?" with In (`bg-ok`) / Out (`bg-steel`) 46px buttons → "Needs your answer" card with a count in club red and dated rows carrying `REPLY` pills → dues card (€ badge, amount, due date, Pay button) → news teaser card with 104px cover, club-red category eyebrow and condensed uppercase headline. + +**M2 · Event · answer for several.** 250px full-bleed photo header with a two-stop scrim, circular back button at the safe-area top, `HOME GAME` club-red badge and a 38px condensed uppercase title. Body: detail card (Face-off / Meet / Where + address / Kit as label-value rows with 78px labels) → "Your answers" card with **one three-state segmented control per person** (In / Maybe / Out, 44px), each person shown with avatar, name, team · number · position, and a `NO REPLY` pill where unanswered; "Add a note for the coach" affordance below → squad-response card with a stacked in/out/silent bar and counts. + +**M3 · Calendar.** Navy header with title, member-scope pill, and List / Month / Games-only filter chips. Body is a 1px-gapped list on a `line` background, grouped under sticky `THIS WEEK` / `NEXT WEEK` labels. Each row: day-of-week + big condensed date, a 3px colour bar for event type (ice = `ice`, other = `warn`) or a 4px left border in club red for games, title, meta (time · team · which of my people), and a status pill (In / Out / Reply / 1 open / Optional). + +**M4 · News article.** 330px portrait photo, three-stop scrim, back button, `ice` eyebrow (team · date), 40px condensed uppercase headline stacked over three lines. Body on white: 18px semibold lede, 16px body paragraphs, tag pills, then a byline row with avatar and a Share secondary button. + +**M5 · Me & my people.** Navy header with 56px avatar, name, "Member since · role". Body: "People I manage" card — one row per managed person plus the account holder marked `(me)`, each with team · number · licence state (problems in `clubDark`) and a chevron → settings list (Personal details, Household & contacts, Payments & dues with a `1 OPEN` pill, Notifications) → dark "Coach mode" promo card with an `ice` icon tile → version line. + +**M6 · Edit personal info.** White sticky header: back chevron, subject's name, Save button in club red. Body: warning banner for the missing medical form → grouped label-value cards (Identity, Contact, Emergency, Consent). Values are 16px medium; mono for dates, register numbers and phone numbers. Consent rows carry 48×28 toggles. + +**M7 · Notifications.** Navy header: "Inbox", "Mark all read" in `ice`, filter chips (All / Action `3` / Club). Body grouped by Today / Earlier this week, rows on a 1px-gapped list. Actionable rows carry a **4px left border** (`club` for action-now, `warn` for warnings) and their action inline: In/Out buttons, Upload, Pay. Informational rows are flat; read rows drop to `opacity-[.72]`. Footer line points at Me → Notifications for push preferences. + +### Coach mode (mobile, dark chrome) + +**C1 · Today.** `ink` header: `ice` crest, team name, "Head coach · name", a team-picker pill, then the role switcher with Coach active (`bg-ice`). The body is a light sheet that **overlaps the header with a 20px top radius** — this is the mode's signature. Content: three stat tiles (Squad / In Sat / Silent, silent in club red) → tonight's session card with an `ink` header strip (`TONIGHT · 19:15` in `ice`) and a 50px `bg-ice` "Check attendance" CTA + overflow button → "Needs you" list, each item a card with a 4px left border by severity (line-up = `club`, silent players = `warn`, member blocker = `ice`) and a right-aligned action → "Also yours": a single `navy` card surfacing the coach's *member-side* obligation, so the two hats never fight. + +**C2 · Bench attendance.** `ink` header with back chevron, "Attendance", session meta, and a progress bar + `14/19` counter. Light sheet: filter chips (All 19 / Silent 5 / Goalies) then a 1px-gapped roster list. Each row: jersey number (condensed 22px tabular), name, position, and a **joined 92×44 two-button control** — check (left, `bg-ok` when in) and cross (right, `bg-club` when out); unset is `bg-rule` with `dim` glyphs. Silent players' rows tint `rowWarn`. Fixed white footer with a 52px `bg-ink` "Save attendance" button. + +**C3 · Game selection / line-up.** Fully dark screen. Header: back, "Line-up", opponent + date, `bg-ice` Publish button, then a mono-ish meta row (dressed / goalies / scratched). Body: one `navy` card per unit (Line 1, Line 2, Defence pairs) containing a grid of player tiles — `bg-steel rounded-[10px]` with a 26px condensed jersey number over a surname; empty slots are `border-[1.5px] border-dashed border-[#2C3B56]` reading `EMPTY`. Below: "Available · drag into a slot" pills; unavailable players (out / silent) are shown at `opacity-50` with the reason appended. + +**C4 · Create event.** White sticky header: Cancel / "New event" / Create (`bg-ice`). Body: three event-type tiles (Practice active in `ink`, Game, Other) → label-value card (Title, Date + Time side by side, Location) → "Who" pills (team with count active, other teams, Goalies only, Pick players) → options card (Ask for attendance toggle, Answers close row, Repeat weekly toggle with the resulting event count) → an `infoBg` note stating how many members get notified and how many have a clash. + +**C5 · Post news.** White sticky header with a club-red Publish. Keyboard is up (this screen is shown mid-composition). 120px cover slot, then the composer: condensed uppercase 28px headline, a 40×2 club-red rule, 16px body with a **club-red caret** at the insertion point, tag pills, and an Audience row ("U16 families · also on club website"). + +**C6 · Add members to team.** `ink` header with back, "Add to U16", squad count, and a `bg-steel` search field. Light sheet: filter chips (Suggested / Age eligible / No team), then sections — "Moving up from U14", "New this season" — of rows with avatar, name, `year · position · licence state`, and a 28px square checkbox (`bg-ok` with a check when selected, `border-[1.5px] border-stroke` when not). Members with a licence problem show it in `clubDark`. Fixed white footer: "2 selected / Squad becomes 21" beside a 52px `bg-ice` Add button. + +### Club management (desktop, 1440×900 reference) + +Shared shell: **236px `bg-ink` sidebar** (crest + club name + `RosterChief · management` in mono, then 40px nav items in Barlow Condensed 700 16px `tracking-[.06em]` uppercase; active item `bg-club text-white rounded-lg`; expanded sub-items are 28–30px 14px rows indented 22px, active in white semibold, counts as pills) + **64px white topbar** (screen title, mono context, spacer, then secondary actions and one club-red primary) + content on `paper`. Several screens add a 48–54px filter/tab strip under the topbar. **Every management screen fits 900px without internal scrolling** — keep that constraint. + +**D1 · Club home.** Five KPI cards (Members, Awaiting approval, Dues collected, Licences missing, Turnout) with 44px condensed numerals and a delta line. Then a 1.35fr/1fr split: left — "Needs attention" list where each row has a 6px severity bar, a title, a detail line and a right-aligned action (Review / Export / Chase / Assign), and below it a "Membership by team" bar chart (ten bars, the focused team in club red, the rest `navy`); right — a dark "This weekend" card (three fixture rows with big condensed dates) over a "Recent activity" card (mono timestamps + actor-first sentences). + +**D2 · Members list.** Topbar with count and Import CSV + New member. Filter strip: search field, applied filters as removable `bg-ink` chips, `+ Filter`, saved-view dropdown. **Bulk bar** (`bg-ink`, 48px): "3 selected", divider, then Move to team / Assign role / Send message / Create invoice in `ice`, Clear on the right. Table columns: checkbox · No. (condensed 18px) · Member (30px avatar + name + `year · sex`) · Team · position · Licence (mono, `okText` or `clubDark` "missing") · Dues pill · Attendance (mono %) · Household. Selected rows tint `rowSel`. Pager row at the bottom. + +**D7 · Member detail.** The members table dimmed to `opacity-50` under a `rgba(11,18,32,.34)` scrim, with a **620px right drawer**. Drawer header: 56px avatar, name + club-red `#9`, a mono provenance line (`member since · id · team · household`), Message + close. Tabs: Profile / Attendance / Finance / Documents / History. Profile body: warning banner naming both blockers with a Request action → two cards side by side (Identity as label-value rows with mono values; Household listing every related person with their relationship, payer and staff roles, plus a sibling-discount note) → season card with a 12-bar attendance sparkline (`ok` present, `club` absent, `edge` upcoming) beside Present / Absent / No-reply numerals → three small stat cards (Open balance, Plan, App). Footer: Save changes (club red), Move team (secondary), and **End membership as red text, never a button**. + +**D3 · Sign-up intake & approval.** Two-pane: left, the queue table (Applicant with source line · Born · Wants · Checks pill · Age, oldest highlighted with a 3px `ice` left border and `rowFocus` tint); right, a **420px detail pane** — applicant header, Checks list (20px square icons, `ok` for passes, `warn` with `!` for gaps), "Place in" team buttons, a fee-plan mini-table ending in an instalment row on `subhead`, and the applicant's own note as a quote. Footer: "Approve & invoice" (`bg-ok`, flex-1) + Hold (secondary). + +**D4 · Team & staff assignment.** 180px team cover photo with a left-weighted scrim, `ice` category eyebrow, 52px condensed team name, meta row, and Export roster / Add player buttons bottom-right. Tab strip: Roster / Staff / Schedule / Attendance / Results / Settings. Content 1.55fr/1fr: left, the roster table (No. · Player with C/A letters in club red · Position · Shoots · Status pill; attention rows tinted); right, a Staff card (avatar, name, `role · rights`, mono "since YYYY"), a "Squad make-up" card (Goalies / Defence / Forwards numerals + the federation minimum stated in prose), and a dark "Blocking the season start" card listing blockers with `ice` actions. + +**D5 · Season calendar planning.** Sidebar gains an "Ice resources" legend (main rink `ice`, training rink `violet`, off-ice `warn`, games `club`). Topbar: week number, mono date range, prev/next, Week/Month/Season switch, "Plan recurring". Main: a 7-column week grid with a 54px mono hour gutter (17:00–23:00) and absolutely positioned event blocks coloured by resource; the focused team's block is `bg-ink` with a 2px club-red border and shows its attendance split. Right rail (300px): a "Plan recurring" form (Team / Pattern / Range / Skip) ending in "Generate 32 events", a Conflicts list (double booking in danger colours, players in two teams in warn colours), and a footnote that publishing pushes to member apps and the public site together. + +**D6 · Dues & billing.** Topbar: Export SEPA / Send reminders / New invoice run. Row one: a dark "Collected" card (46px `€ 84.240`, a `68%` figure in `ice`, a three-segment progress bar, target and last-year comparison) plus three light cards (Open, Overdue 30+ in club red, Instalment plans). Row two 1.7fr/1fr: left, the invoice table (mono invoice number · Household · For · Amount · Due · Age, overdue ages in `clubDark`) with Overdue/All chips in its header; right, an "Aging" card (four labelled bars: not due `ink`, 1–30 `warn`, 31–60 `club`, 60+ `#8C0019`) over a "Reminder ladder" card (four numbered steps escalating in colour) with a note that suspension is a club setting and never automatic for youth. + +**D8 · News list & editor.** Three panes. **400px list pane:** topbar with New post, filter chips (All 42 / Drafts 3 / Scheduled 1), then post rows — status pill + mono meta (date · reads, or "edited N ago"), condensed uppercase headline, `author · team · category`; the open draft is tinted `rowSel` with a 3px club-red left border. **Editor:** its own topbar (mono autosave state, Preview / Schedule / Publish), then a white article card with a 190px cover slot and the article rendered at final typography — club-red category eyebrow, 42px condensed uppercase headline, 56×3 club-red rule, semibold lede, body paragraphs, club-red caret. **300px right rail:** Audience toggles (team families / whole club / public website), a push-reach note, tags, and a note that coaches can post to their own team from the app while club-wide and website posts need a news role. + +**D9 · Club identity & branding.** Topbar shows "unsaved changes" in `warnText` with Discard / Save. Two columns. Left: **Club** card (Club name, Short code in mono, Tagline, Website in mono, Federation), **Colours** card (primary + secondary swatch inputs with mono hex, an "accents only" note in its header, and a contrast-check row showing computed ratios with ✓), **Logo & wordmark** card (76px dashed drop zones for crest SVG and wordmark). Right: **Live preview** card with App / Website / Email tabs — a 232px phone mock (header, role switcher, hero card, skeleton rows) beside a website-header mock and a "Where the brand shows up" list that ends with an explicit *never* (status colours, tables, form fields); below it an **Advanced** card (custom stylesheet with file size and edited date, own domain with a verified state) marked "enabled by RosterChief". + +### Control panel (desktop, 1440×900) + +Deliberately industrial: 4px radii, hairline `edge` borders, mono figures, no decoration. Shared shell: **52px `bg-ink` command bar** (mark + `RosterChief` + mono `control`, then mono tabs — active tab `bg-steel` with a 2px `ice` bottom border — spacer, a `⌘K run command` field or a primary action, and a live status dot) over a **34px white breadcrumb/metrics strip** (mono: environment, deploy, p95, queue depth, alert count in `clubDark`). + +**P1 · Platform health.** Six KPI tiles (clubs live, members, WAU, MRR, zero-event clubs in `warn`, failed jobs in `club`) with mono labels and 38px condensed numerals. Below, 1.6fr/1fr: left, a stacked 12-week sign-up chart (youth `ice` over adult `ink`, square bars, mono week ticks) over a "Club health" table (club · members · WAU · events 30d · plan · risk pill, sorted by risk, the reference tenant tinted `rowFocus`); right, a dark **Alerts** card (mono entries with 2px severity left borders), a **Feature adoption** card (mono flag names with `n/34` counts and square progress bars), and a **Job log** card (mono `time · ok|err · job · detail`). + +**P2 · Club provisioning & feature flags.** Command bar carries a club-red "Provision club" action; breadcrumb strip shows `clubs / slug / settings` plus club id and creation date. Left rail (280px) is a mono club list with health dots and member counts, active club inverted to `bg-ink`. Content: club header (64px crest, 34px condensed name, mono domain/federation/counts line, then Impersonate / Audit log / Save). Below, two columns: left, a **Branding** card (primary + secondary swatch fields, logo and wordmark previews, and a note that colours apply to accents only) over a mono **Plan & billing** card (plan, seats, monthly, renews, last invoice in `ok`); right, a **Feature flags** table — one row per flag with the mono flag name, a plain-language description, an optional `beta` / `soon` chip, and a toggle. Flags shown: `public_site`, `online_payments`, `lineups`, `licence_sync_rbihf`, `instalment_plans`, `licence_suspension`, `shop_beta`, `season_registration`, `custom_stylesheet`, `multi_sport`. + +--- + +## Interactions & behaviour + +**Mode switching.** The switcher renders only if the account has ≥1 staff assignment. Switching swaps the tab bar, the chrome palette (navy/club ↔ ink/ice) and the navigation stack; each mode keeps its own stack position. Persist the last mode per device and restore on launch. Deep links from a notification open the correct mode regardless of the stored one. + +**Person scope.** The person switcher is a horizontally scrolling chip row. Changing it re-scopes the current screen without navigating. Managed people come from the household/family relation; "Me" appears when the account holder is themselves a member. Calendar has an extra "All members" scope. + +**Attendance.** Three states — in / maybe / out — plus an implicit *no reply*. Answers close at a per-event deadline (default 24h before start); after that the control becomes read-only with the reason shown. Coach-side attendance (C2) records actual presence, which is a separate axis from the member's RSVP; both feed the attendance percentage on the member record. + +**Line-up.** Drag a player from the Available row into a unit slot; slots accept one player and swap on drop. Out and silent players stay visible but non-draggable at 50% opacity. Publish notifies only selected players and writes the line-up to the game record. + +**Recurring events.** The planner previews the count before writing ("Generate 32 events"). Conflicts are computed against ice resources and against members in two teams, and are shown before generation, not after. + +**Bulk actions.** Selecting rows reveals the dark bulk bar; the count is authoritative and actions apply to the selection, not the filter. Clear deselects without resetting filters. + +**Approval.** "Approve & invoice" creates the membership, places the person in the chosen team, applies the fee plan, and issues the invoice in one action. Open checks do not block approval — they carry over as tasks on the member record. + +**Motion.** Restrained. Sheet transitions 240ms `cubic-bezier(.2,.8,.2,1)`; drawer slide 240ms; pill/toggle state 120ms; counters may count up on first paint (≤600ms) but nothing loops. No parallax, no decorative animation. + +**States to build that the mocks imply.** Empty (no events / no news / no managed people), loading skeletons matching card geometry (see the D9 preview's skeleton rows for the intended treatment), offline banner for the coach at the rink (attendance must queue and sync), form validation inline under the field in `clubDark`, and permission-denied where a coach lacks a right (hide, don't disable, except where the absence would be confusing). + +## State + +Mobile: `mode` (member|coach, persisted), `scopePerson`, `activeTeam` (coach), per-event `rsvp[personId]`, attendance draft `{memberId: in|out|unset}` (offline-queued), notification read state, filter selections. + +Desktop: route, table filters + saved view, selection set, drawer target + tab, editor draft with autosave timestamp, dirty-form flag (D9 shows it explicitly in the topbar). + +## Assets + +- **Fonts:** Barlow, Barlow Condensed, IBM Plex Mono (Google Fonts, all OFL). +- **Icons:** inline 24×24 stroke-2 SVGs, `currentColor`-ready — bell, home, calendar, news, person, clock, person-plus, plus, chevron, check, cross, search, chart, building, dots. Swap for the codebase's existing icon set if one exists; keep 21px at 2px stroke on mobile tabs. +- **Crest:** clip-path polygon fallback plus `uploads/rosterchief-dark.svg` (the RosterChief shield). Club crests come from `Club.logo`. +- **Photography: not included.** Every `` marks a required real photo: M1 hero + news cover, M2 game action, M4 article portrait, C5 news cover, D4 team cover, D8 article cover. + +## Files in this bundle + +| File | What it is | +|---|---| +| `RosterChief Platform.dc.html` | The design document — all 25 screens. Open in a browser; it is the visual source of truth. | +| `ios-frame.jsx` | Presentation-only iOS bezel used by the mobile screens. Not for production. | +| `image-slot.js` | Photo placeholder component. Not for production. | +| `support.js` | Runtime for the design document. **Do not port.** | +| `rosterchief-dark.svg` | RosterChief shield mark. | +| `github.md` | Repo association and the screen → Django app map. | + +## Suggested build order + +1. Tokens: Tailwind config, fonts, per-tenant CSS custom properties for club colours. +2. Primitives: button, pill, toggle, chip, label-value row, table row, card, tab bar, app header + role switcher. +3. Member mode M1 → M3 → M2 → M7 (the RSVP loop is the product's core). +4. Coach mode C1 → C2 → C3 (attendance and line-up are the reason coaches install anything). +5. Management D2 → D7 → D3 (member data, then intake), then D4, D5, D6, D8, D9. +6. Control panel P1, P2. diff --git a/design_handoff_rosterchief_platform/RosterChief Platform.dc.html b/design_handoff_rosterchief_platform/RosterChief Platform.dc.html new file mode 100644 index 0000000..fcfa230 --- /dev/null +++ b/design_handoff_rosterchief_platform/RosterChief Platform.dc.html @@ -0,0 +1,1783 @@ + + + + + + + + + + + + + + + + + + +
    +
    + 00 +

    Foundations

    +
    +

    One design language across four surfaces. Condensed uppercase display type, scoreboard numerals, tight all-caps labels, and a navy/red core that yields to club branding where the club's own audience sees it.

    + +
    +
    +
    Core palette
    +
    +
    #0B1220
    Ink
    +
    #101E36
    Navy
    +
    #1B2B47
    Steel
    +
    #F4F5F7
    Paper
    +
    #E3E6EB
    Line
    +
    +
    +
    #E4002B
    Club accent
    +
    #14B8E8
    Ice / coach
    +
    #14A05A
    In / paid
    +
    #F0A22E
    Pending
    +
    #6C7787
    Muted
    +
    +
    Club theming replaces accent only — primary + secondary colour, logo and wordmark. Structure, type and neutrals never change.
    +
    + +
    +
    Type
    +
    Fear the Sharks
    +
    Barlow Condensed 800 · display / screen titles
    +
    +
    18
    +
    4—2
    +
    Scoreboard
    numerals
    +
    +
    Body copy is Barlow. It carries member names, event details and long-form news without shouting over the display type.
    +
    + Label / U16 + MONO · IDS, MONEY, TIMESTAMPS +
    +
    +
    + +
    +
    +
    Buttons
    +
    +
    Primary
    +
    Dark
    +
    Secondary
    +
    +
    +
    +
    Status
    +
    + In + Out + No reply + Selected + Draft +
    +
    + Paid + Due + Overdue +
    +
    +
    +
    Role switcher
    +
    +
    Member
    +
    Coach
    +
    +
    +
    Member
    +
    Coach
    +
    +
    Only shown to people who hold a staff role. Mode is remembered per device.
    +
    +
    +
    Surfaces
    +
    +
    Control paneldesktop
    +
    Club managementdesktop
    +
    Coach modemobile
    +
    Member modemobile
    +
    +
    Coach and Member are two modes of one installed app, not two apps.
    +
    +
    +
    + +
    +
    + 01 +

    Member app — mobile

    +
    +

    For everyone in the club: parents answering for their kids, and adult members answering for themselves. The same screens serve both — a person switcher at the top of anything that is per-member.

    + +
    + +
    +
    M1Home
    + +
    +
    +
    +
    +
    Sharks Mechelen
    Season 2026–2027
    +
    +
    +
    +
    Member
    +
    Coach
    +
    +
    +
    +
    +
    LS
    Lars
    +
    NS
    Noor
    +
    Me
    +
    + +
    +
    + +
    +
    Next up · Sat 22 Aug
    Sharks U16 vs Leuven
    +
    +
    +
    18:30 face-offMeet 17:15ISC Mechelen
    +
    Lars — are you in?
    +
    +
    In
    +
    Out
    +
    +
    +
    + +
    +
    Needs your answer3
    +
    +
    25
    Aug
    Practice · Ice 3
    Noor · 19:15–20:45
    Reply
    +
    +
    27
    Aug
    Away · Herentals
    Lars · bus 16:00
    Reply
    +
    +
    + +
    +
    +
    Season dues — Noor
    € 420,00 · due 31 Aug
    +
    Pay
    +
    + +
    +
    Club newsAll news
    +
    +
    +
    Sharks Women · 15 Aug
    Signed: Kolbrun Bjornsdottir
    +
    +
    +
    +
    +
    Home
    +
    Calendar
    +
    News
    +
    Me
    +
    +
    +
    +
    + +
    +
    M2Event · answer for several
    + +
    +
    + +
    +
    +
    +
    Home game
    +
    Sharks U16
    vs Leuven Chiefs
    +
    +
    +
    +
    +
    Face-off
    Sat 22 Aug · 18:30
    +
    Meet
    17:15 · dressing room 4
    +
    Where
    ISC Mechelen
    Spuibeekstraat 1, 2800 Mechelen
    +
    Kit
    Dark jerseys
    +
    + +
    +
    Your answers
    +
    +
    +
    LS
    Lars Somers
    U16 · #17 · Forward
    +
    In
    Maybe
    Out
    +
    +
    +
    +
    NS
    Noor Somers
    U16 · #9 · Defence
    No reply
    +
    In
    Maybe
    Out
    +
    +
    +
    Add a note for the coach +
    +
    + +
    +
    Squad response14 of 19
    +
    +
    11 in3 out5 no reply
    +
    +
    +
    +
    +
    + +
    +
    M3Calendar
    + +
    +
    +
    Calendar
    All members
    +
    +
    List
    +
    Month
    +
    Games only
    +
    +
    +
    +
    This week
    +
    +
    Tue
    19
    Practice · Ice 3 & 4
    19:15–21:00 · U16 · Lars, Noor
    In
    +
    Thu
    21
    Practice · Ice 1 & 2
    17:15–18:45 · U16 · Noor
    Out
    +
    Sat
    22
    Game · vs Leuven Chiefs
    18:30 · Home · meet 17:15
    1 open
    +
    +
    Next week
    +
    +
    Mon
    24
    Practice · U18 & Future
    19:15–20:15 · Lars
    In
    +
    Tue
    25
    Practice · Ice 3
    19:15–20:45 · Noor
    Reply
    +
    Thu
    27
    Away · Herentals
    20:00 · bus leaves 16:00
    Reply
    +
    Sun
    30
    Club day · barbecue
    12:00 · whole club
    Optional
    +
    +
    +
    +
    Home
    +
    Calendar
    +
    News
    +
    Me
    +
    +
    +
    +
    + +
    +
    M4News article
    + +
    +
    + +
    +
    +
    +
    Sharks Women · 15 Aug 2026
    +
    Signed:
    Kolbrun
    Bjornsdottir
    +
    +
    +
    +

    Sharks Women Mechelen add 18-year-old Icelandic forward Kolbrun Bjornsdottir for the DFEL2 Nord campaign.

    +

    She moves to Belgium this month and joins the group for the first on-ice sessions next week. The coaching staff sees her as an immediate fit for the top six, with the pace to play alongside the club's international line.

    +

    A full introduction, including her first interview in Mechelen, follows at the season opener on 12 September.

    +
    + Sharks Women + Transfers +
    +
    BS
    Posted by Bernard S.
    Club communications
    Share
    +
    +
    +
    +
    + +
    +
    M5Me & my people
    + +
    +
    +
    Me
    +
    +
    KS
    +
    Katrien Somers
    Member since 2019 · Team manager U16
    +
    +
    +
    +
    +
    People I manage
    +
    +
    LS
    Lars Somers
    U16 · #17 · licence OK
    +
    NS
    Noor Somers
    U16 · #9 · medical form due
    +
    KS
    Katrien Somers (me)
    Recreational · Div 4 · licence OK
    +
    +
    +
    +
    Personal details
    +
    Household & contacts
    +
    Payments & dues1 open
    +
    Notifications
    +
    +
    +
    +
    Coach mode
    You manage U16 — switch any time
    + +
    +
    RosterChief · Sharks Mechelen · v3.0.1
    +
    +
    +
    Home
    +
    Calendar
    +
    News
    +
    Me
    +
    +
    +
    +
    + +
    +
    M6Edit personal info
    + +
    +
    +
    +
    Noor Somers
    +
    Save
    +
    +
    +
    !
    Medical form for the 2026–2027 season is missing. The club needs it before the first game.
    +
    +
    Identity
    +
    +
    First name
    Noor
    +
    Last name
    Somers
    +
    Date of birth
    14 / 03 / 2011
    +
    National register no.
    11.03.14-234.56
    +
    +
    +
    +
    Contact
    +
    +
    Email
    noor.somers@example.be
    +
    Mobile
    +32 470 12 34 56
    +
    Address
    Kerkstraat 12, 2800 Mechelen
    +
    +
    +
    +
    Emergency
    +
    +
    Contact 1
    Katrien Somers · mother
    +
    Allergies / notes
    None recorded
    +
    +
    +
    +
    Consent
    +
    +
    Photos on club channels
    +
    Share contact with team parents
    +
    +
    +
    +
    +
    +
    + +
    +
    M7Notifications
    + +
    +
    +
    Inbox
    Mark all read
    +
    +
    All
    +
    Action 3
    +
    Club
    +
    +
    +
    +
    Today
    +
    +
    +
    +
    Answer needed · Noor
    Practice Tue 25 Aug 19:15 — replies close tomorrow 19:15
    In
    Out
    + 08:12 +
    +
    +
    !
    +
    Medical form missing · Noor
    Needed before the first game on 5 Sep
    Upload
    + 07:40 +
    +
    +
    +
    New club news
    Signed: Kolbrun Bjornsdottir
    + 06:05 +
    +
    +
    Earlier this week
    +
    +
    +
    +
    Invoice due 31 Aug
    € 420,00 · instalment 2 of 3 · Noor
    +
    Pay
    +
    +
    +
    +
    Line-up published · U16
    Lars is in line 1 for Sat 22 Aug
    + Wed +
    +
    +
    +
    Practice moved
    Thu 21 Aug now 17:15 · Ice 1 & 2
    + Tue +
    +
    +
    +
    You are now team manager · U16
    Coach mode is available in your app
    + Mon +
    +
    +
    Choose which of these arrive as push in Me → Notifications
    +
    +
    +
    Home
    +
    Calendar
    +
    News
    +
    Me
    +
    +
    +
    +
    + +
    +
    +
    +
    + 02 +

    Coach mode — mobile

    +
    +

    Same app, second mode. Dark chrome and an ice-blue accent make it unmistakable which hat you are wearing. Built for the rink: thumb-reachable, glove-sized targets, works with one hand on the bench.

    + +
    + +
    +
    C1Today
    + +
    +
    +
    +
    +
    Sharks U16
    Head coach · Katrien S.
    +
    U16
    +
    +
    +
    Member
    +
    Coach
    +
    +
    +
    +
    +
    19
    Squad
    +
    11
    In Sat
    +
    5
    Silent
    +
    + +
    +
    Tonight · 19:15Ice 3 & 4
    +
    +
    Practice · U16
    +
    15 confirmed · 2 out · 2 silent
    +
    +
    Check attendance
    +
    +
    +
    +
    + +
    +
    Needs you
    +
    +
    Line-up for Sat vs Leuven
    Not submitted · closes Fri 20:00
    Build
    +
    5 players have not answered
    Sat 22 Aug · game
    Nudge
    +
    Noor Somers · medical form
    Blocks game licence
    Remind
    +
    +
    + +
    +
    Also yours
    +
    LS
    Lars needs an answer for Thu
    From your member side
    Open
    +
    +
    +
    +
    Today
    +
    Squad
    +
    Schedule
    +
    Create
    +
    +
    +
    +
    + +
    +
    C2Bench attendance
    + +
    +
    +
    Attendance
    Practice · Tue 19 Aug · 19:15
    +
    +
    + 14/19 +
    +
    +
    +
    All 19
    Silent 5
    Goalies
    +
    +
    1
    Ruben Claes
    Goalie
    +
    9
    Noor Somers
    Defence
    +
    12
    Emma Peeters
    Forward
    +
    14
    Milan De Wit
    No reply
    +
    17
    Lars Somers
    Forward · A
    +
    21
    Jonas Verlinden
    Defence
    +
    23
    Sofie Maes
    Forward
    +
    +
    +
    Save attendance
    +
    +
    +
    + +
    +
    C3Game selection
    + +
    +
    +
    Line-up
    vs Leuven Chiefs · Sat 22 Aug
    Publish
    +
    15 dressed2 goalies4 scratched
    +
    +
    +
    +
    Line 1LW · C · RW
    +
    +
    17
    Somers
    +
    23
    Maes
    +
    12
    Peeters
    +
    +
    +
    +
    Line 2LW · C · RW
    +
    +
    8
    Vermeer
    +
    15
    Aerts
    +
    Empty
    +
    +
    +
    +
    Defence pairs
    +
    +
    21
    Verlinden
    +
    4
    Janssens
    +
    +
    +
    +
    Available · drag into a slot
    +
    +
    27Willems
    +
    6Dhondt
    +
    9Somers · out
    +
    14De Wit · silent
    +
    +
    +
    +
    +
    +
    + +
    +
    C4Create event
    + +
    +
    + Cancel +
    New event
    +
    Create
    +
    +
    +
    +
    Practice
    +
    Game
    +
    Other
    +
    +
    +
    Title
    Practice · Ice 3 & 4
    +
    Date
    Tue 26 Aug
    Time
    19:15 – 21:00
    +
    Location
    Ice Skating Center Mechelen
    +
    +
    +
    Who
    +
    +
    U16 · 19
    +
    U18
    +
    Goalies only
    +
    Pick players
    +
    +
    +
    +
    Ask for attendance
    +
    Answers close
    24h before start
    +
    Repeat weekly
    Until 21 Dec 2026 · 17 events
    +
    +
    19 members will be notified. 3 have a clash with U18 practice on the same slot.
    +
    +
    +
    +
    + +
    +
    C5Post news
    + +
    +
    + Cancel +
    Post news
    +
    Publish
    +
    +
    +
    +
    U16 takes the derby in Leuven
    +
    +
    Two goals from Sofie Maes and a clean third period gave the U16 a 4—2 win in Leuven. Next home game is Saturday
    +
    + U16 + Game report + + tag +
    +
    Audience
    U16 families · also on club website
    +
    +
    +
    +
    + +
    +
    C6Add members to team
    + +
    +
    +
    Add to U16
    Season 2026–2027 · 19 in squad
    +
    Search club members
    +
    +
    +
    Suggested
    Age eligible
    No team
    +
    Moving up from U14
    +
    +
    TV
    Tuur Vandael
    2011 · Forward · licence OK
    +
    AD
    Anna Dierckx
    2011 · Defence · licence OK
    +
    MB
    Matteo Bruyns
    2011 · licence pending
    +
    +
    New this season
    +
    +
    SK
    Sam Koninckx
    2010 · joined 2 Aug · no team
    +
    IV
    Ines Vercauteren
    2010 · goalie · no team
    +
    +
    +
    2 selected
    Squad becomes 21
    Add
    +
    +
    +
    + +
    +
    +
    +
    + 03 +

    Club management — desktop

    +
    +

    Secretariat, treasurer and board. Dense tables, bulk actions and keyboard-friendly filters. Same type and colour system as the apps, tuned for long sessions on a 1440-wide screen.

    + +
    + +
    +
    D1Club home
    +
    +
    +
    +
    +
    Sharks Mechelen
    RosterChief · management
    +
    +
    +
    Overview
    +
    Members
    +
    Teams
    +
    Calendar
    +
    News
    +
    Finance
    +
    Settings
    +
    +
    +
    BS
    Bernard S.
    Secretary
    +
    +
    +
    +
    Season 2026–2027
    +
    Active
    +
    +
    Search members, teams, events⌘K
    +
    New member
    +
    +
    +
    +
    Members
    312
    +18 vs last season
    +
    Awaiting approval
    7
    oldest 4 days
    +
    Dues collected
    68%
    € 84.240 of € 123.900
    +
    Licences missing
    11
    across 5 teams
    +
    Turnout · 30d
    81%
    −4 pts vs June
    +
    +
    +
    +
    Needs attentionClear all
    +
    +
    7 sign-ups waiting for approval
    U8 (3), U10 (2), Div 4 (2)
    Review
    +
    11 members without a valid licence
    Federation deadline 1 Sep
    Export
    +
    23 invoices overdue by 30+ days
    € 9.840 outstanding
    Chase
    +
    U14 has no head coach assigned
    Season starts in 27 days
    Assign
    +
    +
    +
    Membership by team
    +
    +
    U8
    +
    U10
    +
    U12
    +
    U14
    +
    U16
    +
    U18
    +
    U23
    +
    Women
    +
    Div 2
    +
    Div 4
    +
    +
    +
    +
    +
    +
    This weekend
    +
    +
    22
    Sat
    U16 vs Leuven Chiefs
    18:30 · home · line-up pending
    +
    +
    23
    Sun
    Women Nat vs Tilburg
    17:00 · away · bus booked
    +
    +
    23
    Sun
    U10 tournament · Sportiom
    08:00–18:00 · 4 volunteers short
    +
    +
    +
    +
    Recent activity
    +
    +
    09:12
    Katrien S. published news “U16 takes the derby”
    +
    08:47
    Payment € 420,00 received · Somers, N.
    +
    08:31
    Sign-up Ines Vercauteren · goalie · U16
    +
    Yest.
    Tom V. assigned as assistant coach · U12
    +
    Yest.
    Calendar 17 U16 practices generated
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    D2Members — list & bulk actions
    +
    +
    +
    Sharks Mechelen
    RosterChief · management
    +
    +
    Overview
    +
    Members
    +
    +
    All members
    +
    Sign-ups 7
    +
    Households
    +
    Roles
    +
    +
    Teams
    +
    Calendar
    +
    News
    +
    Finance
    +
    Settings
    +
    +
    +
    +
    +
    Members
    + 312 total · 24 shown +
    +
    Import CSV
    +
    New member
    +
    +
    +
    Name, email, licence no.
    +
    Team: U16 ✕
    +
    Status: active ✕
    +
    + Filter
    +
    + Saved view +
    U16 squad ▾
    +
    +
    + 3 selected +
    + Move to team + Assign role + Send message + Create invoice +
    + Clear +
    +
    +
    + No.MemberTeam · positionLicenceDuesAttendanceHousehold +
    +
    + 17LSLars Somers
    2011 · M
    U16 · ForwardBE-2411893Paid92%Somers +
    +
    + 9NSNoor Somers
    2011 · F
    U16 · DefencemissingDue78%Somers +
    +
    + 23SMSofie Maes
    2010 · F
    U16 · ForwardBE-2411655Paid96%Maes +
    +
    + 1RCRuben Claes
    2010 · M
    U16 · GoalieBE-2410322Paid88%Claes +
    +
    + 12EPEmma Peeters
    2011 · F
    U16 · ForwardBE-2411901Paid84%Peeters +
    +
    + 14MDMilan De Wit
    2011 · M
    U16 · ForwardBE-2411774Overdue54%De Wit +
    +
    + 21JVJonas Verlinden
    2010 · M
    U16 · DefenceBE-2410188Paid90%Verlinden +
    +
    + 4LJLotte Janssens
    2011 · F
    U16 · DefenceBE-2411442Paid81%Janssens +
    +
    + 8FVFinn Vermeer
    2010 · M
    U16 · ForwardBE-2410967Paid73%Vermeer +
    +
    + 15WAWout Aerts
    2011 · M
    U16 · ForwardBE-2411510Paid79%Aerts +
    +
    + 27SWStan Willems
    2011 · M
    U16 · ForwardBE-2411388Due68%Willems +
    +
    Showing 11 of 24
    12
    +
    +
    +
    +
    + +
    +
    D3Sign-up intake & approval
    +
    +
    +
    Sharks Mechelen
    RosterChief · management
    +
    +
    Overview
    +
    Members
    +
    +
    All members
    +
    Sign-ups 7
    +
    Households
    +
    Roles
    +
    +
    Teams
    +
    Calendar
    +
    News
    +
    Finance
    +
    Settings
    +
    +
    +
    +
    +
    +
    Sign-ups
    + 7 waiting +
    +
    Approve all clean
    +
    +
    +
    ApplicantBornWantsChecksAge
    +
    + IVInes Vercauteren
    via website form
    + 2010U16 · goalie2 open4 d +
    +
    + TBThijs Bogaert
    sibling of Lore B. (U12)
    + 2018U8 · skaterClean2 d +
    +
    + NKNora Kestens
    try-out 12 Aug
    + 2016U10 · skaterClean2 d +
    +
    + PDPieter Daems
    adult · recreational
    + 1989Div 4 · skater1 open1 d +
    +
    + SKSam Koninckx
    transfer · Antwerp Phantoms
    + 2010U16 · forwardTransfer1 d +
    +
    + AVAya Van Loo
    via website form
    + 2018U8 · skaterClean6 h +
    +
    + RBRuth Bekaert
    adult · Women Nat
    + 2004Women · defenceClean3 h +
    +
    +
    +
    +
    +
    IV
    Ines Vercauteren
    Applied 12 Aug · U16 goalie
    +
    +
    +
    +
    Checks
    +
    +
    Age eligible for U16
    +
    Household contact verified
    +
    !Medical form not uploaded
    +
    !Federation licence to request
    +
    +
    +
    +
    Place in
    +
    + U16 + U18 + Women +
    +
    +
    +
    Fee plan
    +
    +
    Youth competitive 2026–27€ 780,00
    +
    Sibling discount— € 0,00
    +
    Instalments3 × € 260,00
    +
    +
    +
    “Ines played four years with Antwerp. Looking for a club closer to home.” — note from the sign-up form
    +
    +
    +
    Approve & invoice
    +
    Hold
    +
    +
    +
    +
    +
    + +
    +
    D4Team & staff assignment
    +
    +
    +
    Sharks Mechelen
    RosterChief · management
    +
    +
    Overview
    +
    Members
    +
    Teams
    +
    +
    U8 · U10 · U12
    +
    U14
    +
    U16
    +
    U18 · U23
    +
    Women Nat · NL
    +
    Div 2 · Div 3 · Div 4
    +
    +
    Calendar
    +
    News
    +
    Finance
    +
    Settings
    +
    +
    +
    +
    + +
    +
    +
    Youth · competitive
    +
    Sharks U16
    +
    19 players4 staffSeason 2026–2027Roster locked 1 Sep
    +
    +
    +
    Export roster
    +
    Add player
    +
    +
    +
    + Roster + StaffScheduleAttendanceResultsSettings +
    +
    +
    +
    Roster · 19Sort: number
    +
    No.PlayerPositionShootsStatus
    +
    1Ruben ClaesGoalieLReady
    +
    4Lotte JanssensDefenceRReady
    +
    6Mika DhondtDefenceLReady
    +
    8Finn VermeerForward · CLReady
    +
    9Noor SomersDefenceRMedical
    +
    12Emma PeetersForwardRReady
    +
    14Milan De WitForwardLDues
    +
    15Wout AertsForward · CRReady
    +
    17Lars Somers AForward · LWLReady
    +
    21Jonas Verlinden CDefenceRReady
    +
    23Sofie MaesForward · RWRReady
    +
    +
    +
    +
    Staff+ Assign
    +
    KS
    Katrien Somers
    Head coach · full app rights
    since 2024
    +
    TV
    Tom Verhoeven
    Assistant coach
    since 2025
    +
    HD
    Hilde De Clerck
    Team manager · no line-ups
    since 2023
    +
    GB
    Gert Bosmans
    Goalie coach · read only
    since 2026
    +
    +
    +
    Squad make-up
    +
    +
    2
    Goalies
    +
    6
    Defence
    +
    11
    Forwards
    +
    +
    Federation minimum for a game sheet is 2 goalies and 12 skaters. Met.
    +
    +
    +
    Blocking the season start
    +
    +
    Noor Somers · medical formRemind
    +
    Milan De Wit · dues 60 daysChase
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    D5Season calendar planning
    +
    +
    +
    Sharks Mechelen
    RosterChief · management
    +
    +
    Overview
    +
    Members
    +
    Teams
    +
    Calendar
    +
    News
    +
    Finance
    +
    Settings
    +
    +
    +
    Ice resources
    +
    +
    ISCM · main rink
    +
    ISCM · training rink
    +
    Off-ice · gym
    +
    Games
    +
    +
    +
    +
    +
    +
    Week 35
    + 24 – 30 Aug 2026 +
    +
    +
    WeekMonthSeason
    +
    Plan recurring
    +
    +
    +
    +
    +
    +
    17:00
    18:00
    19:00
    20:00
    21:00
    22:00
    23:00
    +
    +
    Mon 24
    U18 + Future
    19:15–20:15
    Women Int
    20:30–21:30
    +
    Tue 25
    Ice 1 & 2
    17:30–19:00
    U16 practice
    19:15–21:00 · Ice 3 & 4
    15 in · 2 out
    +
    Wed 26
    Women Nat
    18:30–19:30
    U18
    19:45–20:45
    Div III
    22:15–23:30
    +
    Thu 27
    Ice 1 & 2
    17:15–18:45
    Away · Herentals
    20:00 · bus 16:00
    +
    Fri 28
    Off-ice U16
    18:30–19:30 · gym
    +
    Sat 29
    U16 vs Turnhout
    18:30 · home
    Line-up open
    +
    Sun 30
    Club barbecue
    12:00–17:00
    Whole club · optional
    +
    +
    +
    +
    +
    Plan recurring
    +
    +
    Team
    U16
    +
    Pattern
    Tue 19:15 · Thu 17:15
    +
    Range
    01 Sep → 21 Dec
    +
    Skip
    Autumn break, 25 Dec
    +
    +
    Generate 32 events
    +
    +
    +
    Conflicts
    +
    +
    Thu 27 · double booking
    Away game overlaps Ice 3 & 4 for U16
    +
    3 players in two teams
    U16 / U18 Monday slot
    +
    +
    +
    Published events push to member apps and the public club site at the same time.
    +
    +
    +
    +
    +
    + +
    +
    D6Dues & billing
    +
    +
    +
    Sharks Mechelen
    RosterChief · management
    +
    +
    Overview
    +
    Members
    +
    Teams
    +
    Calendar
    +
    News
    +
    Finance
    +
    +
    Dues
    +
    Fee plans
    +
    Payments
    +
    Sponsors
    +
    +
    Settings
    +
    +
    +
    +
    +
    Dues · 2026–2027
    +
    +
    Export SEPA
    +
    Send reminders
    +
    New invoice run
    +
    +
    +
    +
    +
    Collected
    +
    € 84.24068%
    +
    +
    Target € 123.900Last year 71% at this date
    +
    +
    Open
    € 30.020
    86 invoices
    +
    Overdue 30+
    € 9.840
    23 invoices · 19 households
    +
    Instalment plans
    41
    3 defaulted
    +
    +
    +
    +
    InvoicesOverdueAll
    +
    InvoiceHouseholdForAmountDueAge
    +
    2627-0184De WitMilan · U16€ 780,0015 Jun68 d
    +
    2627-0091WillemsStan · U16€ 260,0001 Jul52 d
    +
    2627-0142AertsWout · U16€ 780,0005 Jul48 d
    +
    2627-0067Van Loo2 members · U8 + U12€ 1.180,0010 Jul43 d
    +
    2627-0203SomersNoor · U16€ 420,0031 Augdue
    +
    2627-0177BogaertLore · U12€ 640,0020 Jul33 d
    +
    2627-0058KestensNora · U10€ 540,0022 Jul31 d
    +
    +
    +
    +
    Aging
    +
    +
    Not due€ 14.320
    +
    1–30 days€ 5.860
    +
    31–60 days€ 6.180
    +
    60+ days€ 3.660
    +
    +
    +
    +
    Reminder ladder
    +
    +
    1Friendly nudge in the app · day 7
    +
    2Email to household · day 21
    +
    3Coach informed · day 45
    +
    4Game licence suspended · day 60
    +
    +
    Suspension is a club setting — never automatic for youth teams unless switched on.
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    D7Member detail
    +
    +
    +
    Sharks Mechelen
    RosterChief · management
    +
    +
    Overview
    +
    Members
    +
    Teams
    +
    Calendar
    +
    News
    +
    Finance
    +
    Settings
    +
    +
    +
    +
    Members
    312 total
    +
    +
    No.MemberTeamLicenceDues
    +
    17Lars SomersU16 · ForwardBE-2411893Paid
    +
    9Noor SomersU16 · DefencemissingDue
    +
    23Sofie MaesU16 · ForwardBE-2411655Paid
    +
    1Ruben ClaesU16 · GoalieBE-2410322Paid
    +
    +
    +
    +
    +
    +
    NS
    +
    +
    Noor Somers#9
    +
    member since 2021 · id 8f21·04 · U16 defence · household Somers
    +
    +
    Message
    +
    +
    + ProfileAttendanceFinanceDocumentsHistory +
    +
    +
    !
    Medical form and federation licence both outstanding — blocks the game sheet from 5 Sep.
    Request
    +
    +
    +
    Identity
    +
    +
    Born2011-03-14
    +
    Register no.11.03.14-234.56
    +
    Emailnoor.somers@example.be
    +
    Mobile+32 470 12 34 56
    +
    Photo consentGiven
    +
    +
    +
    +
    Household · Somers
    +
    +
    KS
    Katrien Somers
    mother · payer · coach
    +
    DS
    Dirk Somers
    father
    +
    LS
    Lars Somers
    brother · U16
    +
    Sibling discount applies to invoice 2627-0203
    +
    +
    +
    +
    +
    Season 2026–2027attendance 78% · 24 of 31 events
    +
    +
    +
    +
    +
    +
    24
    Present
    +
    5
    Absent
    +
    2
    No reply
    +
    +
    +
    +
    +
    Open balance
    € 420,00
    +
    Plan
    3 × € 260
    +
    App
    Active
    +
    +
    +
    +
    Save changes
    +
    Move team
    +
    + End membership +
    +
    +
    +
    + +
    +
    D8News — list & editor
    +
    +
    +
    Sharks Mechelen
    RosterChief · management
    +
    +
    Overview
    +
    Members
    +
    Teams
    +
    Calendar
    +
    News
    +
    +
    Posts
    +
    Drafts 3
    +
    Categories
    +
    Website pages
    +
    +
    Finance
    +
    Settings
    +
    +
    +
    +
    News
    New post
    +
    All 42Drafts 3Scheduled 1
    +
    +
    Draftedited 4 min ago
    U16 takes the derby in Leuven
    Katrien S. · U16 · game report
    +
    Live15 Aug · 1.204 reads
    Signed: Kolbrun Bjornsdottir
    Bernard S. · Sharks Women · transfers
    +
    Live10 Aug · 890 reads
    Women ready for a new season
    Bernard S. · Sharks Women
    +
    Scheduled01 Sep · 08:00
    Practice schedule 2026–2027
    Bernard S. · club · practical
    +
    Live07 Aug · 1.633 reads
    New season, new Sharks
    Bernard S. · club
    +
    Draftedited 2 d ago
    Volunteers wanted · U10 tournament
    Hilde D. · club
    +
    +
    +
    +
    + draft · autosaved 4 min ago +
    +
    Preview
    +
    Schedule
    +
    Publish
    +
    +
    +
    +
    +
    +
    +
    U16 · game report
    +
    U16 takes the derby in Leuven
    +
    +

    Two goals from Sofie Maes and a clean third period gave the U16 a 4—2 win in Leuven.

    +

    The first period was even, with Ruben Claes keeping the score level after a shaky start. From the second period on the pressure told: Maes converted twice inside four minutes, and Verlinden closed the game out with a shorthanded goal.

    +

    Next home game is Saturday against Turnhout, face-off 18:30 at ISC Mechelen.

    +
    +
    +
    +
    +
    +
    Audience
    +
    +
    U16 families
    +
    Whole club
    +
    Public website
    +
    +
    +
    +
    Push notification
    +
    Sends to 38 households · silent for members who muted news.
    +
    +
    +
    Tags
    +
    + U16 + Game report + + add +
    +
    +
    Coaches can post to their own team from the app. Club-wide and website posts need a news role.
    +
    +
    +
    +
    +
    + +
    +
    D9Club identity & branding
    +
    +
    +
    Sharks Mechelen
    RosterChief · management
    +
    +
    Overview
    +
    Members
    +
    Teams
    +
    Calendar
    +
    News
    +
    Finance
    +
    Settings
    +
    +
    Identity & branding
    +
    Seasons
    +
    Roles & rights
    +
    Fee plans
    +
    Locations
    +
    Notifications
    +
    +
    +
    +
    +
    +
    Identity & branding
    +
    + unsaved changes +
    Discard
    +
    Save
    +
    +
    +
    +
    +
    Club
    +
    +
    +
    Club name
    Sharks Mechelen
    +
    Short code
    SHKM
    +
    +
    Tagline
    Fear the Sharks
    +
    +
    Website
    sharksmechelen.be
    +
    Federation
    RBIHF
    +
    +
    +
    +
    +
    Coloursaccents only
    +
    +
    +
    Primary
    #0B1220
    +
    Secondary
    #E4002B
    +
    +
    +
    Contrast check
    +
    +
    White on secondary5.1 ✓
    +
    White on primary16.4 ✓
    +
    +
    +
    +
    +
    +
    Logo & wordmark
    +
    +
    Crest · SVG
    +
    Wordmark
    Sharks MechelenSVG · 240×40 · replace
    +
    +
    +
    +
    +
    +
    Live preview
    AppWebsiteEmail
    +
    +
    +
    +
    Sharks Mechelen
    +
    Member
    Coach
    +
    +
    +
    Next up
    U16 vs Leuven
    +
    +
    +
    +
    +
    +
    Sharks MechelenTeams · ClubJoin
    +
    #Fear the Sharks
    +
    +
    +
    Where the brand shows up
    +
    +
    App header, active tab, primary buttons
    +
    Public site nav, hero, join CTA
    +
    Invoice header and reminder emails
    +
    Never: status colours, tables, form fields
    +
    +
    +
    +
    +
    +
    +
    Advancedenabled by RosterChief
    +
    +
    Custom stylesheet · public site
    sharks.css · 4,2 kB · edited 07 Aug
    Edit
    +
    Own domain
    sharksmechelen.be · verified
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + 04 +

    Control panel — internal

    +
    +

    Three or four people run every club on the platform from here. Industrial by intent: square corners, hairline rules, monospaced figures, no decoration — a dark command bar over a dense light workspace.

    + +
    + +
    +
    P1Platform health
    +
    +
    +
    RosterChiefcontrol
    +
    + platform + clubs + features + billing + admins + jobs +
    +
    +
    ⌘K run command
    +
    all systems ok
    +
    +
    + prod · eu-west-1|deploy v3.0.1 · 4 d ago|p95 218 ms|queue 3|2 alerts +
    +
    +
    +
    clubs live
    34
    +3 this quarter
    +
    members
    11.482
    +412 · 30 d
    +
    wau
    6.907
    60% of members
    +
    mrr
    € 8.940
    +€ 620
    +
    zero-event clubs
    4
    no events 14 d
    +
    failed jobs
    2
    sepa export
    +
    +
    +
    +
    +
    Sign-ups · 12 weeksyouth ▨ · adult ▧
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    w23w26w29w32w35
    +
    +
    +
    Club healthsorted by risk
    +
    clubmemberswauevents 30dplanrisk
    +
    IHC Turnhout148210clubhigh
    +
    Leuven Chiefs2037814clubwatch
    +
    Herentals HC17610422clubok
    +
    Sharks Mechelen31224138club+siteok
    +
    Gent Rockets986111starterok
    +
    Brussels Bulldogs22115829clubok
    +
    Liège Bulldogs1348817starterok
    +
    +
    +
    +
    +
    Alerts
    +
    +
    sepa_export failed · 2×
    clubs: turnhout, gent · 06:12 UTC
    +
    turnhout · 0 events in 14 d
    onboarding stalled after import
    +
    +
    +
    +
    Feature adoption
    +
    +
    public_site18/34
    +
    online_payments27/34
    +
    lineups21/34
    +
    shop_beta3/34
    +
    +
    +
    +
    Job log
    +
    +
    09:40oklicence_sync rbihf · 214 rows
    +
    09:00okdigest_email · 6.907 sent
    +
    06:12errsepa_export · turnhout
    +
    06:12errsepa_export · gent
    +
    03:00okbackup_snapshot · 42 GB
    +
    02:30okattendance_rollup · 34 clubs
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    P2Club provisioning & feature flags
    +
    +
    +
    RosterChiefcontrol
    +
    + platform + clubs + features + billing + admins + jobs +
    +
    +
    Provision club
    +
    +
    + clubs/sharks-mechelen/settingsid 0f3a·c21b|created 2019-08-02 +
    +
    +
    +
    filter clubs…
    +
    +
    sharks-mechelen312
    +
    brussels-bulldogs221
    +
    leuven-chiefs203
    +
    herentals-hc176
    +
    ihc-turnhout148
    +
    liege-bulldogs134
    +
    gent-rockets98
    +
    namur-rangers76
    +
    hasselt-huskies64
    +
    +
    +
    +
    +
    SM
    +
    +
    Sharks Mechelen
    +
    sharks.rosterchief.app · sharksmechelen.be · RBIHF · 312 members · 14 teams
    +
    +
    +
    Impersonate
    +
    Audit log
    +
    Save
    +
    +
    +
    +
    +
    +
    Branding
    +
    +
    +
    primary_color
    #0B1220
    +
    secondary_color
    #E4002B
    +
    +
    logo · wordmark
    Sharks Mechelen
    +
    Colours apply to accents only. Structure, type and neutrals are locked platform-wide.
    +
    +
    +
    +
    Plan & billing
    +
    +
    planclub + public_site
    +
    seats312 / 400
    +
    monthly€ 312,00
    +
    renews2027-07-01
    +
    last invoicepaid · 2026-08-01
    +
    +
    +
    +
    +
    Feature flags7 on · 3 off
    +
    +
    public_site
    Club website with news, teams, calendar
    +
    online_payments
    Mollie · SEPA + Bancontact
    +
    lineups
    Game selection in coach mode
    +
    licence_sync_rbihf
    Nightly federation licence pull
    +
    instalment_plans
    Split dues over 3 payments
    +
    licence_suspension
    Auto-suspend on 60 d overdue
    +
    shop_beta
    Club shop · 3 pilot clubs
    beta
    +
    season_registration
    Self-service renewal in member app
    soon
    +
    custom_stylesheet
    Escape hatch for the public site
    +
    multi_sport
    Non-hockey disciplines in one club
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + 05 +

    How it hangs together

    +
    +

    One account, one identity, four surfaces. Rights decide what a person sees; nobody installs a second app to wear a second hat.

    +
    +
    +
    Surface 01
    +
    Control panel
    +
    Platform staff only. Provisioning, feature flags, billing, job health, impersonation with an audit trail.
    +
    desktop only
    light + dark command bar
    controlpanel/
    +
    +
    +
    Surface 02
    +
    Club management
    +
    Board, secretary, treasurer. Members, intake, teams and staff, season calendar, dues, news, club branding.
    +
    desktop only
    club accent on navy
    management/
    +
    +
    +
    Surface 03 · mode
    +
    Coach mode
    +
    Anyone with a staff role on a team. Attendance, line-ups, events, news, squad changes — a subset of what management can do, shaped for the rink.
    +
    mobile first
    dark chrome + ice accent
    same app as surface 04
    +
    +
    +
    Surface 04 · mode
    +
    Member mode
    +
    Every member and every parent. Answers for themselves and for the people they manage — never a separate “parent” account.
    +
    mobile first
    club accent on navy
    shop + renewal land here
    +
    +
    +
    +
    +
    One identity
    +
    A person is one account with a set of memberships and roles. Katrien is a Div 4 player, mother of two U16 players, and head coach of U16 — three facts about one row, not three logins.
    +
    +
    +
    Rights, not apps
    +
    The mode switch only appears when a staff role exists. Management rights open the desktop surface in the same session — no second sign-in, no second password.
    +
    +
    +
    Club theming
    +
    Primary and secondary colour, logo and wordmark come from the club record and reach every surface a club member sees. The control panel stays platform-branded.
    +
    +
    +
    + + +
    + + + diff --git a/design_handoff_rosterchief_platform/github.md b/design_handoff_rosterchief_platform/github.md new file mode 100644 index 0000000..415da64 --- /dev/null +++ b/design_handoff_rosterchief_platform/github.md @@ -0,0 +1,27 @@ +repo: bsiebens/RosterChief +branch: main + +## Last sync +date: 2026-08-16T00:00:00Z + +### Updated in this project +- Clean-sheet restructure into four surfaces: control panel, club management, and one mobile app with Coach / Member modes (role switcher in the header, remembered per device). +- "Parents app" renamed and rethought as the Member app — adult members and parents use the same screens, with a person switcher on anything per-member. +- New design language: Barlow Condensed display, scoreboard numerals, navy/red core, ice-blue coach accent; club theming limited to primary + secondary colour, logo and wordmark. +- Added member detail drawer, news list + editor, club branding settings and the member notification inbox. +- Control panel moved to an industrial light workspace under a dark command bar, monospaced figures throughout. + +## Sync history +date: 2026-08-08T16:59:19Z — control panel rebuilt around the real statistics service; club detail stat groups; dark hero band. +date: 2026-08-08T16:18:27Z — first clean-sheet design for three layers; domain model from the repo's Django apps. + +## Screen map +| Screen | Repo files | +| --- | --- | +| Foundations (palette, type, status, role switcher, surface map) | club/models.py (Club.primary_color, secondary_color, logo) | +| M1–M6 Member app: home, event RSVP for several, calendar, news article, me & my people, edit personal info | news/models.py, events/models.py (Attendance), members/models.py (Family), club/models.py (ClubMembership) | +| C1–C6 Coach mode: today, bench attendance, line-up / game selection, create event, post news, add members to team | events/models.py, teams/models.py (Position, StaffAssignment), news/models.py | +| D1–D6 Management: club home, members list & bulk actions, sign-up intake & approval, team & staff assignment, season calendar planning, dues & billing | management/urls.py, members/models.py, teams/models.py, events/models.py, billing/models.py | +| D7–D9 Management: member detail drawer, news list & editor, club identity & branding | members/models.py, news/models.py, club/models.py (primary_color, secondary_color, logo, custom stylesheet) | +| M7 Member app: notification inbox | events/models.py (Attendance), news/models.py, billing/models.py | +| P1–P2 Control panel: platform health, club provisioning & feature flags | controlpanel/services/statistics.py, controlpanel/views.py, features/models.py, billing/models.py | diff --git a/design_handoff_rosterchief_platform/image-slot.js b/design_handoff_rosterchief_platform/image-slot.js new file mode 100644 index 0000000..e30189b --- /dev/null +++ b/design_handoff_rosterchief_platform/image-slot.js @@ -0,0 +1,1225 @@ +// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) +// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected). +/* BEGIN USAGE */ +/** + * — user-fillable image placeholder. + * + * Drop this into a deck, mockup, or page wherever a design needs an image. + * You control the slot's shape; it sizes to its container by default. When the search_stock_photos tool + * is available, prefill the slot by default — write the photo's URL into + * src (with credit/credit-href); the user can still fill or replace it + * by dragging an image file onto it (or clicking to browse). The dropped + * image persists across reloads via a .image-slots.state.json sidecar — + * same read-via-fetch / write-via-window.omelette pattern as + * design_canvas.jsx, so the filled slot shows on share links, downloaded + * zips, and PPTX export. Outside the omelette runtime the slot is read-only. + * + * The sidecar is a SIBLING of the HTML file that uses this component: the + * read is a document-relative fetch, and the host resolves the bridge's + * sidecar writes into the previewed file's directory to match (same + * contract as design_canvas.jsx). Pages in the same directory share one + * sidecar; keep slot ids distinct across them. + * + * Attributes: + * id Persistence key. REQUIRED for the drop to survive reload — + * every slot on the page needs a distinct id. + * shape 'rect' | 'rounded' | 'circle' | 'pill' (default 'rounded') + * 'circle' applies 50% border-radius; on a non-square slot + * that's an ellipse — set equal width and height for a true + * circle. + * radius Corner radius in px for 'rounded'. (default 12) + * mask Any CSS clip-path value. Overrides `shape` — use this for + * hexagons, blobs, arbitrary polygons. + * fit Initial framing baseline: cover | contain. (default 'cover') + * cover starts the image filling the frame (overflow cropped); + * contain starts it fully visible (letterboxed). Either way the + * user can always pan/scale from there — double-click, or the + * Edit control, enters reframe mode (drag to move, scroll or + * corner-handles to scale; Escape / click-out commits). The + * crop persists alongside the image in the sidecar. + * placeholder Empty-state caption. (default 'Drop an image') + * src Optional initial/fallback image URL. Prefill it with a real + * photo via search_stock_photos when that tool is available + * (set credit/credit-href from the result). A user drop + * overrides it; clearing the drop reveals src again. + * credit Attribution text shown as a small overlay at the + * bottom-left of the filled slot. REQUIRED whenever src + * points at any Unsplash host (images.unsplash.com, + * plus.unsplash.com, …): an Unsplash src with no credit + * renders an error tile INSTEAD of the photo (Unsplash + * terms forbid showing their photos unattributed). Use the + * exact form 'Photo by {photographer name} on Unsplash' — + * the overlay then links the name to credit-href and + * 'Unsplash' to the Unsplash homepage, and links back to + * unsplash.com automatically get the required utm referral + * params appended at render time. The credit belongs to + * the src image, so it only shows while src is what's + * displayed — a user-dropped image hides it. + * credit-href Link for the photographer's name in the credit overlay + * (their Unsplash profile URL from the stock-photo search + * results). http(s) URLs only — anything else renders the + * name as plain text. + * + * Sizing: the slot fills its container by default (width/height 100%). + * Put it in a sized wrapper — absolutely positioned, a grid cell, a fixed + * frame — and it takes exactly that box. When the parent's height is + * indefinite (ordinary flow), it falls back to full width at a 3:2 aspect + * ratio instead of collapsing. In a shrink-to-fit parent (a float, + * width:max-content, an unsized absolute wrapper), percentages have + * nothing to resolve against — size the slot or its wrapper explicitly + * there. For a fixed-size slot, set + * width/height on the element itself (inline style), which overrides the + * default. When + * layering content above a slot (full-bleed layouts), make the overlay + * click-through — pointer-events: none on scrims/text plates, re-enabled + * on interactive children — so the slot's hover controls stay reachable. + * Keep the slot's bottom-left corner visually clear as well: the credit + * overlay renders there, and a dark fade or text plate covering it hides + * the attribution Unsplash's terms require — end the fade above that + * corner, or keep it nearly transparent where the credit sits. + * + * Usage: + *
    + * + *
    + * + * + * + */ +/* END USAGE */ + +(() => { + const STATE_FILE = '.image-slots.state.json'; + + // Unsplash terms require visible attribution wherever their photos + // display, and every link back to unsplash.com must carry utm referral + // params. Two render-time rules enforce that here: + // - an Unsplash-src slot with NO credit attribute renders an error + // tile INSTEAD of the photo (an uncredited Unsplash photo on screen + // is itself the terms violation, so it never renders bare); + // - rendered credit links pointing at unsplash.com get the referral + // params appended when absent (credit-href values live in page + // content that can't be edited after the fact). + // Keep the utm_source value in sync with UTM_SOURCE in + // platform/web-agent/unsplash.ts — this file is a project-local + // artifact and cannot import it (equality is pinned by tests). + const UNSPLASH_HOMEPAGE_HREF = + 'https://unsplash.com/?utm_source=claude_design&utm_medium=referral'; + // Host rule mirrors the hotlink validator that admits Unsplash srcs into + // pages in the first place (cdn$ in unsplash.ts: apex or any subdomain) + // — Unsplash+ results serve from plus.unsplash.com, not just images.*, + // and an admitted-but-uncredited photo must error whatever unsplash + // host it rides on. + // Trailing-dot FQDNs (images.unsplash.com.) are the same host to the + // browser but would miss the regex — strip one dot so the check fails + // CLOSED (unrecognized-but-real Unsplash srcs must error, not render). + const isUnsplashHost = (u) => { + try { + return /(^|\.)unsplash\.com$/.test( + new URL(u, document.baseURI).hostname.replace(/\.$/, '') + ); + } catch { + return false; + } + }; + // Render-time referral normalization for links back to Unsplash: + // appends utm_source/utm_medium when absent, preserves every existing + // query param, never overwrites an existing utm_source, and passes + // non-Unsplash URLs through untouched. Input is an ABSOLUTE validated + // http(s) URL (the credit render funnel resolves + validates first). + const withReferral = (href) => { + try { + const u = new URL(href); + if (!/(^|\.)unsplash\.com$/.test(u.hostname.replace(/\.$/, ''))) { + return href; + } + if (!u.searchParams.has('utm_source')) { + u.searchParams.set('utm_source', 'claude_design'); + } + if (!u.searchParams.has('utm_medium')) { + u.searchParams.set('utm_medium', 'referral'); + } + return u.toString(); + } catch (e) { + return href; + } + }; + // 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the + // sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB. + const MAX_DIM = 1200; + // Raster formats only. SVG is excluded (can carry script; createImageBitmap + // on SVG blobs is inconsistent). GIF is excluded because the canvas + // re-encode keeps only the first frame, so an animated GIF would silently + // go still — better to reject than surprise. + const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif']; + + // ── Shared sidecar store ──────────────────────────────────────────────── + // One fetch + immediate write-on-change for every on the + // page. Reads via fetch() so viewing works anywhere the HTML and sidecar + // are served together; writes go through window.omelette.writeFile, which + // the host allowlists to *.state.json basenames only. + const subs = new Set(); + let slots = {}; + // ids explicitly cleared before the sidecar fetch resolved — otherwise + // the merge below can't tell "never set" from "just deleted" and would + // resurrect the sidecar's stale value. + const tombstones = new Set(); + let loaded = false; + let loadP = null; + + function load() { + if (loadP) return loadP; + loadP = fetch(STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + // Merge: sidecar loses to any in-memory change that raced ahead of + // the fetch (drop or clear) so neither is clobbered by hydration. + if (j && typeof j === 'object') { + const merged = Object.assign({}, j, slots); + // A framing-only write that raced ahead of hydration must not + // drop a user image that's only on disk — inherit u from the + // sidecar for any in-memory entry that lacks one. + for (const k in slots) { + if (merged[k] && !merged[k].u && j[k]) { + merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u; + } + } + for (const id of tombstones) delete merged[id]; + slots = merged; + } + tombstones.clear(); + }) + .catch(() => {}) + .then(() => { loaded = true; subs.forEach((fn) => fn()); }); + return loadP; + } + + // Serialize writes so two near-simultaneous drops on different slots + // can't reorder at the backend and leave the sidecar with only the + // first. A save requested mid-flight just marks dirty and re-fires on + // completion with the then-current slots. + let saving = false; + let saveDirty = false; + // Unload-time flush: save()'s serialization defers a mid-RTT re-fire to a + // .then that never runs in an unloading document, silently dropping a + // pagehide commit. Post the current slots immediately instead — content + // is a superset snapshot of any in-flight save's, the write is a + // whole-file last-writer-wins replace, and postMessage FIFO delivers it + // to the host after the in-flight one, so a backend-side reorder at + // worst reproduces the dropped-commit outcome this flush improves on. + // Guarded on the initial sidecar read: pre-hydration slots can miss + // other slots' persisted entries, and flushing it would clobber them — + // that narrow case stays best-effort (the in-memory merge in load() + // cannot happen in an unloading document anyway). + function flushNow() { + if (!loaded) return; + const w = window.omelette && window.omelette.writeFile; + if (!w) return; + try { Promise.resolve(w(STATE_FILE, JSON.stringify(slots))).catch(() => {}); } catch (e) {} + } + function save() { + if (saving) { saveDirty = true; return; } + const w = window.omelette && window.omelette.writeFile; + if (!w) return; + saving = true; + Promise.resolve(w(STATE_FILE, JSON.stringify(slots))) + .catch(() => {}) + .then(() => { saving = false; if (saveDirty) { saveDirty = false; save(); } }); + } + + const S_MAX = 5; + const clampS = (s) => Math.max(1, Math.min(S_MAX, s)); + + // Normalize a stored slot value. Pre-reframe sidecars stored a bare + // data-URL string; newer ones store {u, s, x, y}. Either shape is valid. + function getSlot(id) { + const v = slots[id]; + if (!v) return null; + return typeof v === 'string' ? { u: v, s: 1, x: 0, y: 0 } : v; + } + + function setSlot(id, val) { + if (!id) return; + if (val) { slots[id] = val; tombstones.delete(id); } + else { delete slots[id]; if (!loaded) tombstones.add(id); } + subs.forEach((fn) => fn()); + // A drop is rare + high-value — write immediately so nav-away can't lose + // it. Gate on the initial read so we don't overwrite a sidecar we haven't + // merged yet; the merge in load() keeps this change once the read lands. + if (loaded) save(); else load().then(save); + } + + // ── Image downscale ───────────────────────────────────────────────────── + // Encode through a canvas so the sidecar carries resized bytes, not the + // raw upload. Longest side is capped at 2× the slot's rendered width + // (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG + // for photos, so there's no need for per-image format picking. + async function toDataUrl(file, targetW) { + const bitmap = await createImageBitmap(file); + try { + const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM); + const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height)); + const w = Math.max(1, Math.round(bitmap.width * scale)); + const h = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = document.createElement('canvas'); + canvas.width = w; canvas.height = h; + canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h); + return canvas.toDataURL('image/webp', 0.85); + } finally { + bitmap.close && bitmap.close(); + } + } + + // ── Custom element ────────────────────────────────────────────────────── + const stylesheet = + // Fill the container by default: slots are usually placed inside a + // sized wrapper (a hero frame, a grid cell, an inset:0 layer) and are + // expected to take that box — a fixed intrinsic size would render as + // a small tile in the corner of a full-bleed wrapper instead. + // aspect-ratio is the companion fallback that keeps a bare slot + // visible when the parent's height is indefinite: height:100% + // resolves to auto there, and the ratio then derives height from + // width instead of letting the slot collapse to zero height. + // Explicit width/height on the element override all of this. + // color:inherit (not a fixed near-black): the placeholder chrome — + // empty-state icon/caption (currentColor) and the dashed ring — must + // read on dark decks too, and the slide's own text color is the one + // color guaranteed to contrast with the slide background. The soft + // look comes from opacity on those parts, not from a baked-in alpha. + ':host{display:block;position:relative;' + + ' font:13px/1.3 system-ui,-apple-system,sans-serif;' + + ' width:100%;height:100%;aspect-ratio:3/2}' + + '.empty .cap,.empty .sub{opacity:.75}' + + '.frame{position:absolute;inset:0;overflow:hidden;background:rgba(127,127,127,.08)}' + + // .frame img (clipped) and .spill (unclipped ghost + handles) share the + // same left/top/width/height in frame-%, computed by _applyView(), so the + // inside-mask crop and the outside-mask spill stay pixel-aligned. + '.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' + + ' -webkit-user-drag:none;user-select:none;touch-action:none}' + + // Reframe mode (double-click): the full image spills past the mask. The + // spill layer is sized to the IMAGE bounds so its corners are where the + // resize handles belong. The ghost inside is translucent; the real + // clipped underneath shows the opaque in-mask crop. + // popover=manual promotes the spill to the top layer on reframe, so it is + // not clipped by any overflow:hidden / clip-path / scroll-container + // ancestor (a plain z-index can't escape overflow clipping). UA popover + // defaults (inset:0;margin:auto) are reset; _applyView sets viewport px. + '.spill{position:fixed;margin:0;inset:auto;border:0;padding:0;background:transparent;' + + ' overflow:visible;transform:translate(-50%,-50%);z-index:1;cursor:grab;touch-action:none}' + + ':host([data-panning]) .spill{cursor:grabbing}' + + '.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' + + ' pointer-events:none;-webkit-user-drag:none;user-select:none;' + + ' box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' + + '.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' + + ' background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' + + ' transform:translate(-50%,-50%)}' + + '.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' + + '.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' + + '.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' + + '.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' + + ':host([data-reframe]){z-index:10}' + + ':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' + + '.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' + + ' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' + + ' cursor:pointer;user-select:none}' + + '.empty svg{opacity:.45}' + + '.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' + + '.empty .sub{font-size:11px}' + + '.empty .sub u{text-underline-offset:2px}' + + '.empty:hover .sub{opacity:1}' + + ':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' + + ' background:rgba(201,100,66,.10)}' + + '.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed currentColor;' + + ' opacity:.35;transition:border-color .12s,opacity .12s}' + + ':host([data-over]) .ring{border-color:#c96442;opacity:1}' + + ':host([data-filled]) .ring{display:none}' + + // Controls overlay INSIDE the frame, pinned to the top-right corner, so + // a full-bleed slot in an overflow:hidden container still shows them + // (the old below-mask placement got clipped). Credit sits bottom-left, + // so top-right avoids collision. The blurred pill background keeps them + // legible over the image. + // The UA [popover] base rule styles the element in EVERY state (only + // display:none is gated on :not(:popover-open), and the display:flex + // below overrides that) — so the UA resets live HERE, like .spill's, + // or the ordinary hover-state strip renders as a bordered Canvas box + // centered by margin:auto. inset:auto precedes top/right (shorthand). + '.ctl{position:absolute;inset:auto;top:8px;right:8px;margin:0;border:0;padding:0;' + + ' background:transparent;overflow:visible;' + + ' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' + + ' white-space:nowrap}' + + // While reframing, the spill owns the top layer and would swallow every + // click on the in-frame controls. Promoting .ctl into the top layer + // ABOVE the spill (shown after it — later popovers stack higher) keeps + // Edit-as-toggle and Replace clickable mid-reframe. _applyView pins it + // to the frame's top-right in viewport px (translateX(-100%) + // right-aligns against the computed left edge); inset:auto clears the + // base rule's top/right so the inline left/top position it alone. + '.ctl:popover-open{position:fixed;inset:auto;transform:translateX(-100%)}' + + ':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' + + ' {opacity:1;pointer-events:auto}' + + '.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' + + ' background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' + + ' backdrop-filter:blur(6px)}' + + '.ctl button:hover{background:rgba(0,0,0,.8)}' + + '.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' + + ' background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}' + + // Replacement in flight: after a src swap the browser keeps painting + // the PREVIOUS image until the new one decodes, so a Replace would + // flash the old photo and then pop. Hide the stale frame (visibility, + // not display — _applyView geometry still applies) and spin until the + // new image reports in (load/error clears data-swapping). + ':host([data-swapping]) .frame img{visibility:hidden}' + + '.loading{position:absolute;inset:0;display:none;align-items:center;' + + ' justify-content:center;pointer-events:none}' + + ':host([data-swapping]) .loading{display:flex}' + + '.loading::after{content:"";width:22px;height:22px;border-radius:50%;' + + ' border:2px solid rgba(127,127,127,.25);border-top-color:currentColor;' + + ' animation:om-slot-spin .7s linear infinite}' + + '@keyframes om-slot-spin{to{transform:rotate(360deg)}}' + + // Reduced motion: the static two-tone ring still reads as "working". + '@media (prefers-reduced-motion:reduce){.loading::after{animation:none}}' + + '.credit{position:absolute;left:6px;bottom:6px;max-width:calc(100% - 12px);display:none;' + + ' padding:3px 7px;border-radius:5px;background:rgba(0,0,0,.55);color:#fff;' + + ' font:10px/1.2 system-ui,-apple-system,sans-serif;text-decoration:none;' + + ' white-space:nowrap;overflow:hidden;text-overflow:ellipsis;backdrop-filter:blur(6px)}' + + // The credit is a SPAN holding one or two s (Unsplash's prescribed + // form links the photographer AND Unsplash) — anchors style inline so + // the overlay reads as one line of text. + '.credit a{color:inherit;text-decoration:none}' + + '.credit a:hover,.credit a:focus-visible{text-decoration:underline}' + + ':host([data-filled][data-credit]) .credit{display:block}' + + // Exports must ship JUST the image — no hover controls, no credit chip + // (the host marks for the capture window; the + // page-level hide script can't reach shadow DOM, this rule can). + ':host-context([data-om-exporting]) .ctl,' + + ':host-context([data-om-exporting]) .credit{display:none !important}' + + // Print must ship just the image too: the hover-gated controls can be + // mid-hover when print() fires, and the credit chip is screen chrome — + // the same rule the capture window gets, keyed on print media instead + // of the host's data-om-exporting mark (the print path sets no mark). + '@media print{.ctl,.credit{display:none !important}}' + + // No export-window mask rules here on purpose: the export capture + // releases the replacement mask by REMOVING data-swapping (the + // shadow-root pass in pages/export/shared.ts HIDE_EXPORT_CHROME_SCRIPT) + // — attribute removal works in every engine (:host-context is + // Chromium-only), is scoped by construction to slots actually + // mid-swap, and hides the spinner through the same gate. A masked img + // would otherwise be silently dropped from PPTX decks (the capture + // walk skips visibility:hidden imgs). + // Attribution error tile: REPLACES the photo when an Unsplash src has + // no credit attribute — rendering the photo uncredited is the terms + // violation, so the photo must not appear at all. + // Calm and neutral on purpose (review feedback): the tile informs the + // user; the fix instructions are machine-facing (usage docblock, tool + // description, and the turn-end scan's bounce copy name the attributes + // for the agent). + '.attr-error{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;' + + ' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' + + ' background:#f2f1ef;color:#6e6c66;user-select:none;' + + ' font:13px/1.45 system-ui,-apple-system,sans-serif}' + + '.attr-error svg{opacity:.55}' + + '.attr-error .cap{max-width:92%;font-weight:500;letter-spacing:.01em}' + + ':host([data-attribution-error]) .attr-error{display:flex}' + + ':host([data-attribution-error]) .ring{display:none}'; + + const icon = + '' + + '' + + ''; + + const warnIcon = + '' + + '' + + ''; + + class ImageSlot extends HTMLElement { + static get observedAttributes() { + return ['shape', 'radius', 'mask', 'fit', 'placeholder', 'src', 'id', 'credit', 'credit-href']; + } + + /** Duplicate-slide hook (called by deck-stage, see its + * _remintDuplicateIds): copy this id's stored image, if any, under a + * freshly minted key and return that key — so a duplicated slide's + * slot keeps its dropped photo instead of reverting to the + * placeholder. 'isFree' is the caller's uniqueness check (document + * ids); candidates must ALSO be unused in the sidecar, which can + * hold keys from other pages sharing the project root. (An EMPTY + * slot on another page leaves no sidecar entry, so its id is not + * detectable here — a minted key can collide with it and that slot + * would show this photo. Same blast radius as two pages reusing an + * id by hand, which the shared sidecar already permits.) Returns null + * when no id could be minted (caller strips the id, today's + * behavior). */ + static cloneSlot(fromId, isFree) { + if (typeof fromId !== 'string' || !fromId) return null; + // Pre-hydration the store can't veto candidates or source the copy + // — degrade to the strip (today's behavior) rather than mint + // against keys we can't see yet. Any rendered (= droppable) slot + // means load() has already settled. + if (!loaded) return null; + const stem = fromId.replace(/-\d+$/, '') || fromId; + for (let n = 2; n < 100; n++) { + const toId = stem + '-' + n; + if (toId === fromId) continue; + if (slots[toId] !== undefined) { + // Reuse a key holding this exact value (bytes AND crop) if no + // live element here owns it — a duplicate op the host refused + // after minting leaves such a key behind, and reusing keeps + // refused retries from accumulating one orphaned copy per + // attempt. Full equality (not just bytes) so a byte-identical + // key another PAGE owns with its own crop is stepped past, not + // adopted or rewritten. (Entries without .u never match.) + const prev = getSlot(toId); + const cur = getSlot(fromId); + if (!(prev && cur && prev.u && prev.u === cur.u && + prev.s === cur.s && prev.x === cur.x && prev.y === cur.y && + (typeof isFree !== 'function' || isFree(toId)))) continue; + return toId; + } + if (typeof isFree === 'function' && !isFree(toId)) continue; + const v = getSlot(fromId); + if (v) setSlot(toId, Object.assign({}, v)); + return toId; + } + return null; + } + + constructor() { + super(); + // clonable: rail thumbnails deep-clone slides and carry this shadow + // along; reuse an already-cloned root so upgrade-after-clone works. + // (Deliberately NOT serializable — a getHTML consumer would embed + // multi-MB sidecar data-URLs into serialized page HTML.) + const root = this.shadowRoot || + this.attachShadow({ mode: 'open', clonable: true }); + // .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius + // on the frame (circle, pill, rounded) can't clip them. + root.innerHTML = + '' + + '
    ' + + ' ' + + '
    ' + icon + + '
    ' + + '
    or browse files
    ' + + '
    ' + warnIcon + + '
    This photo needs attribution
    ' + + '
    ' + + '
    ' + + '
    ' + + // Outside .frame, like .spill/.ctl — the frame's overflow:hidden + + // border-radius/clip-path would cut the credit off on circle/pill/mask. + // A SPAN, not an
    : the prescribed Unsplash credit holds two links + // (photographer + Unsplash), built per-render in _render(). + '' + + '
    ' + + ' ' + + '
    ' + + '
    ' + + '
    ' + + // data-dc-edit-transparent: the DC editor's edit-mode picker lets + // clicks through for chrome marked with it (EDIT_TRANSPARENT_SEL) + // — without it, Replace/Edit clicks in Edit mode are swallowed by + // element selection and the controls look dead. + '
    ' + + '
    ' + + ''; + this._frame = root.querySelector('.frame'); + this._ring = root.querySelector('.ring'); + this._img = root.querySelector('.frame img'); + this._empty = root.querySelector('.empty'); + this._cap = root.querySelector('.cap'); + this._sub = root.querySelector('.sub'); + this._spill = root.querySelector('.spill'); + this._ctl = root.querySelector('.ctl'); + this._credit = root.querySelector('.credit'); + this._attrError = root.querySelector('.attr-error'); + // Credit clicks open the link, not browse/reframe. + this._credit.addEventListener('click', (e) => e.stopPropagation()); + this._credit.addEventListener('dblclick', (e) => e.stopPropagation()); + this._ghost = root.querySelector('.ghost'); + this._err = null; + this._input = root.querySelector('input'); + this._depth = 0; + this._gen = 0; + // Encode-in-flight marker (the owning _ingest generation): while set, + // the same-src "nothing in flight" clear in _render must not fire — + // the stored value still points at the OLD image until the encode + // lands, so that clear would unmask the stale image mid-replace. + this._swapGen = 0; + // Render-owned swap in flight: set when _render assigns a new src, + // cleared only by the img's own load/error (or the empty branch). + // img.complete CANNOT stand in for this — setting src only QUEUES + // the current-request swap (a microtask), so synchronously after an + // assignment, complete still reports the OLD settled request. The + // pick path does exactly that: the host sets src, credit, and + // credit-href back-to-back in one task, and renders #2/#3 would + // read the stale complete === true and drop the mask one render + // after it was set. + this._loadPending = false; + // See _render's empty branch: a transient attribution-error wipe of a + // showing image must make the follow-up render a replacement (spinner), + // not a first fill (blank frame). + this._hidShowing = false; + this._view = { s: 1, x: 0, y: 0 }; + this._subFn = () => this._render(); + // Shadow-DOM listeners live with the shadow DOM — bound once here so + // disconnect/reconnect (e.g. React remount) doesn't stack handlers. + this._empty.addEventListener('click', () => this._input.click()); + root.addEventListener('click', (e) => { + const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act'); + if (!act) return; + // The hidden controls are opacity-0 but still tabbable — without + // this gate a keyboard user could drive them on a read-only share + // link (mirrors the dblclick handler's editable gate). + if (!this.hasAttribute('data-editable')) return; + if (act === 'replace') { + this._exitReframe(true); + // Host-owned picker (Unsplash modal; it also offers local import). + this.dispatchEvent(new CustomEvent('image-slot:pick', { + bubbles: true, composed: true, detail: { id: this.id || null } + })); + } + if (act === 'edit') { + if (!this._reframes()) return; + if (this.hasAttribute('data-reframe')) this._exitReframe(true); + else this._enterReframe(); + } + }); + this._input.addEventListener('change', () => { + const f = this._input.files && this._input.files[0]; + if (f) this._ingest(f); + this._input.value = ''; + }); + // naturalWidth/Height aren't known until load — re-apply so the cover + // baseline is computed from real dimensions, not the 100%×100% fallback. + // load/error also release the replacement-in-flight mask (via the + // single discipline in _releaseMask): the swap is only revealed once + // the new image can actually paint (on error the frame shows its + // background, same as a fresh slot with a broken src). + this._img.addEventListener('load', () => { + this._loadPending = false; + this._releaseMask(true); + this._applyView(); + }); + this._img.addEventListener('error', () => { + this._loadPending = false; + this._releaseMask(true); + }); + // Gated only on editable — any filled slot can be repositioned/scaled, + // regardless of fit. Share links (no writeFile) stay static. + this.addEventListener('dblclick', (e) => { + if (!this.hasAttribute('data-editable') || !this._reframes()) return; + e.preventDefault(); + if (this.hasAttribute('data-reframe')) this._exitReframe(true); + else this._enterReframe(); + }); + // Pan + resize both originate on the spill layer. A handle pointerdown + // drives an aspect-locked resize anchored at the opposite corner; any + // other pointerdown on the spill pans. Offsets are frame-% so a + // reframed slot survives responsive resize / PPTX export. + this._spill.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || !this.hasAttribute('data-reframe')) return; + e.preventDefault(); + e.stopPropagation(); + this._spill.setPointerCapture(e.pointerId); + const rect = this.getBoundingClientRect(); + const fw = rect.width || 1, fh = rect.height || 1; + const corner = e.target.getAttribute && e.target.getAttribute('data-c'); + let move; + if (corner) { + // Resize about the OPPOSITE corner. Viewport-px throughout (rect + // fw/fh, not clientWidth) so the math survives a transform:scale() + // ancestor — deck_stage renders slides scaled-to-fit. + const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1; + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + const base = contain ? Math.min(fw / iw, fh / ih) : Math.max(fw / iw, fh / ih); + const sx = corner.includes('e') ? 1 : -1; + const sy = corner.includes('s') ? 1 : -1; + const s0 = this._view.s; + const w0 = iw * base * s0, h0 = ih * base * s0; + const cx0 = (50 + this._view.x) / 100 * fw; + const cy0 = (50 + this._view.y) / 100 * fh; + const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2; + const diag0 = Math.hypot(w0, h0); + const ux = sx * w0 / diag0, uy = sy * h0 / diag0; + move = (ev) => { + const proj = (ev.clientX - rect.left - ox) * ux + + (ev.clientY - rect.top - oy) * uy; + const s = clampS(s0 * proj / diag0); + const d = diag0 * s / s0; + this._view.s = s; + this._view.x = (ox + ux * d / 2) / fw * 100 - 50; + this._view.y = (oy + uy * d / 2) / fh * 100 - 50; + this._clampView(); + this._applyView(); + }; + } else { + this.setAttribute('data-panning', ''); + const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y }; + move = (ev) => { + this._view.x = start.x + (ev.clientX - start.px) / fw * 100; + this._view.y = start.y + (ev.clientY - start.py) / fh * 100; + this._clampView(); + this._applyView(); + }; + } + const up = () => { + try { this._spill.releasePointerCapture(e.pointerId); } catch {} + this._spill.removeEventListener('pointermove', move); + this._spill.removeEventListener('pointerup', up); + this._spill.removeEventListener('pointercancel', up); + this.removeAttribute('data-panning'); + this._dragUp = null; + }; + // Stashed so _exitReframe (Escape / outside-click mid-drag) can + // tear the capture + listeners down synchronously. + this._dragUp = up; + this._spill.addEventListener('pointermove', move); + this._spill.addEventListener('pointerup', up); + this._spill.addEventListener('pointercancel', up); + }); + // Wheel zoom stays available inside reframe mode as a trackpad nicety — + // zooms toward the cursor (offset' = cursor·(1-k) + offset·k). + this.addEventListener('wheel', (e) => { + if (!this.hasAttribute('data-reframe')) return; + e.preventDefault(); + const r = this.getBoundingClientRect(); + const cx = (e.clientX - r.left) / r.width * 100 - 50; + const cy = (e.clientY - r.top) / r.height * 100 - 50; + const prev = this._view.s; + const next = clampS(prev * Math.pow(1.0015, -e.deltaY)); + if (next === prev) return; + const k = next / prev; + this._view.s = next; + this._view.x = cx * (1 - k) + this._view.x * k; + this._view.y = cy * (1 - k) + this._view.y * k; + this._clampView(); + this._applyView(); + }, { passive: false }); + } + + connectedCallback() { + // Warn once per page — an id-less slot works for the session but + // cannot persist, and two id-less slots would share nothing. + if (!this.id && !ImageSlot._warned) { + ImageSlot._warned = true; + console.warn(' without an id will not persist its dropped image.'); + } + this.addEventListener('dragenter', this); + this.addEventListener('dragover', this); + this.addEventListener('dragleave', this); + this.addEventListener('drop', this); + subs.add(this._subFn); + // The host may inject window.omelette.writeFile AFTER the first render; + // re-render on hover so the editable-gated controls reliably appear. + this.addEventListener('pointerenter', this._subFn); + // width%/height% in _applyView encode the frame aspect at call time — + // a host resize (responsive grid, pane divider) would stretch the + // image until the next _render. Re-render on size change: _render() + // re-seeds _view from stored before clamp/apply, so a shrink→grow + // cycle round-trips instead of ratcheting x/y toward the narrower + // frame's clamp range. + this._ro = new ResizeObserver(() => this._render()); + this._ro.observe(this); + load(); + this._render(); + } + + disconnectedCallback() { + subs.delete(this._subFn); + this.removeEventListener('pointerenter', this._subFn); + this.removeEventListener('dragenter', this); + this.removeEventListener('dragover', this); + this.removeEventListener('dragleave', this); + this.removeEventListener('drop', this); + if (this._ro) { this._ro.disconnect(); this._ro = null; } + // commit=false: a disconnect is not a user intent — committing here + // would persist whatever half-finished drag a React remount or DOM + // splice happened to interrupt. Deliberate exits commit on their own + // paths (Escape/click-out/toggle), and unloads commit via pagehide. + this._exitReframe(false); + } + + _enterReframe() { + if (this.hasAttribute('data-reframe')) return; + this.setAttribute('data-reframe', ''); + this._signalReframe(true); + // Best-effort commit when the document unloads mid-reframe (a host + // navigation racing the enter signal, a manual reload, tab close): + // the sidecar write rides the host bridge, which outlives this + // document, so the crop survives even though the mode dies with the + // DOM. Held on the instance so _exitReframe detaches exactly what + // was attached. + this._pagehide = () => { this._exitReframe(true); flushNow(); }; + window.addEventListener('pagehide', this._pagehide); + // Promote spill to the top layer, then keep it pinned over the frame: + // scroll/resize cover the common cases, and a per-frame rect check + // catches layout shifts that fire neither (an image above finishing + // load, streamed DOM pushing the slot down, an ancestor transform + // change) so the overlay can't detach from the frame. + try { this._spill.showPopover(); } catch {} + // After the spill, so the controls stack above it in the top layer. + try { this._ctl.showPopover(); } catch {} + this._reposition = () => { if (this.hasAttribute('data-reframe')) this._applyView(); }; + window.addEventListener('scroll', this._reposition, true); + window.addEventListener('resize', this._reposition); + this._lastRect = ''; + this._watch = () => { + if (!this.hasAttribute('data-reframe')) return; + const r = this.getBoundingClientRect(); + const key = r.left + ',' + r.top + ',' + r.width + ',' + r.height; + if (key !== this._lastRect) { this._lastRect = key; this._applyView(); } + this._watchId = requestAnimationFrame(this._watch); + }; + this._watchId = requestAnimationFrame(this._watch); + this._applyView(); + // Close on click outside (the spill handler stopPropagation()s so + // in-image drags don't reach this) and on Escape. Listeners are held + // on the instance so _exitReframe / disconnectedCallback can detach + // exactly what was attached. + this._outside = (e) => { + if (e.composedPath && e.composedPath().includes(this)) return; + this._exitReframe(true); + }; + this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); }; + document.addEventListener('pointerdown', this._outside, true); + document.addEventListener('keydown', this._esc, true); + } + + _exitReframe(commit) { + if (!this.hasAttribute('data-reframe')) return; + if (this._dragUp) this._dragUp(); + this.removeAttribute('data-reframe'); + this.removeAttribute('data-panning'); + if (this._outside) document.removeEventListener('pointerdown', this._outside, true); + if (this._esc) document.removeEventListener('keydown', this._esc, true); + this._outside = this._esc = null; + if (this._reposition) { + window.removeEventListener('scroll', this._reposition, true); + window.removeEventListener('resize', this._reposition); + this._reposition = null; + } + if (this._watchId) { cancelAnimationFrame(this._watchId); this._watchId = 0; } + if (this._pagehide) { + window.removeEventListener('pagehide', this._pagehide); + this._pagehide = null; + } + try { this._spill.hidePopover(); } catch {} + try { this._ctl.hidePopover(); } catch {} + this._ctl.style.left = ''; this._ctl.style.top = ''; + if (commit) this._commitView(); + this._signalReframe(false); + } + + // Reframe state lives only in this DOM until commit, invisible to the + // host's dirty signals — announce enter/exit so the host can hold + // auto-reloads for exactly the gesture (the guest bundle forwards + // image-slot:reframe to the host as imageSlotReframe). Dispatched on + // the element (composed, so it escapes shadow roots) while connected; + // a disconnected exit (disconnectedCallback) falls back to document so + // the host still hears it. + _signalReframe(active) { + const target = this.isConnected ? this : document; + target.dispatchEvent(new CustomEvent('image-slot:reframe', { + bubbles: true, composed: true, + detail: { active: active, id: this.id || null } + })); + } + + // Public: host's "Import from computer" calls this to run local browse. + openFilePicker() { this._exitReframe(true); this._input.click(); } + + // A src write is a newer intent for this slot's content — the host + // pick path (setImageSlotImage) or an agent edit — so it must win + // over any encode still in flight from an earlier drop: left live, + // that encode lands later, passes _ingest's gen guard, and its + // setSlot silently overwrites the pick (the stored value shadows + // src in _render). Bumping _gen kills the encode before its own + // _swapGen clear runs, so clear the dead claim here too — otherwise + // _releaseMask (gated on !_swapGen) never fires and the pick's + // spinner is stranded. src ONLY: the pick sets credit/credit-href + // in the same task, and clearing _swapGen on those would let the + // same-src branch unmask the old image mid-encode. + attributeChangedCallback(name, oldVal, newVal) { + if (name === 'src' && oldVal !== newVal) { + this._gen++; + this._swapGen = 0; + } + if (this.shadowRoot) this._render(); + } + + // handleEvent — one listener object for all four drag events keeps the + // add/remove symmetric and the depth counter correct. + handleEvent(e) { + if (e.type === 'dragenter' || e.type === 'dragover') { + // Without preventDefault the browser never fires 'drop'. + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + if (e.type === 'dragenter') this._depth++; + this.setAttribute('data-over', ''); + } else if (e.type === 'dragleave') { + // dragenter/leave fire for every descendant crossing — count depth + // so hovering the icon inside the empty state doesn't flicker. + if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); } + } else if (e.type === 'drop') { + e.preventDefault(); + e.stopPropagation(); + this._depth = 0; + this.removeAttribute('data-over'); + const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; + if (f) this._ingest(f); + } + } + + async _ingest(file) { + this._setError(null); + if (!file || ACCEPT.indexOf(file.type) < 0) { + this._setError('Drop a PNG, JPEG, WebP, or AVIF image.'); + return; + } + // toDataUrl can take hundreds of ms on a large photo. A Clear or a + // newer drop during that window would be clobbered when this await + // resumes — bump + capture a generation so stale encodes bail. + const gen = ++this._gen; + // Replacing a shown image: surface the swap through the encode too, + // not just the decode — otherwise the old photo sits there with no + // feedback while the canvas re-encode runs. An empty slot keeps its + // placeholder (no spinner) until the encode lands, as before. + // _swapGen guards the mask against re-renders DURING the encode + // (pointerenter, ResizeObserver, another slot's store write): the + // stored value still resolves to the old image there, so _render's + // same-src clear would otherwise unmask it mid-replace. + if (this.hasAttribute('data-filled')) { + this.setAttribute('data-swapping', ''); + this._swapGen = gen; + } + try { + const w = this.clientWidth || this.offsetWidth || MAX_DIM; + const url = await toDataUrl(file, w); + if (gen !== this._gen) return; + // Only exit reframe once the new image is in hand — a rejected type + // or decode failure leaves the in-progress crop untouched. + this._exitReframe(false); + // Clear BEFORE setSlot: its synchronous re-render must see no + // pending encode, so a byte-identical re-upload (same data URL, no + // load event coming) still clears the mask via the complete branch. + this._swapGen = 0; + const val = { u: url, s: 1, x: 0, y: 0 }; + setSlot(this.id || '', val); + // Keep a session-local copy for id-less slots so the drop still + // shows, even though it cannot persist. + if (!this.id) { this._local = val; this._render(); } + } catch (err) { + if (gen !== this._gen) return; + this._swapGen = 0; + // Reveal the kept old image — unless another replacement (a + // remote pick's src swap) is still in flight, in which case the + // mask stays until THAT image settles (its load/error releases). + this._releaseMask(); + this._setError('Could not read that image.'); + console.warn(' ingest failed:', err); + } + } + + _setError(msg) { + if (this._err) { this._err.remove(); this._err = null; } + if (!msg) return; + const d = document.createElement('div'); + d.className = 'err'; d.textContent = msg; + this.shadowRoot.appendChild(d); + this._err = d; + setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000); + } + + // Reframing (pan/resize) is available on any filled slot — the user can + // always reposition/scale. `fit` only sets the initial baseline (see + // _geom): contain starts fully-visible, cover starts frame-filling. + _reframes() { + return this.hasAttribute('data-filled'); + } + + // The single release discipline for the replacement-in-flight mask + // (data-swapping). The mask comes off only when BOTH hold: + // - no encode is pending (_swapGen) — mid-encode the stored value + // still resolves to the old image, so any reveal paints it; + // - the frame img has settled on its current src — an unsettled src + // means some replacement is still in flight (e.g. a remote pick), + // whoever started it, and revealing would paint the previous + // frame. The load/error listeners pass settled=true (the event IS + // the settlement signal, per spec complete is true by then); + // other callers rely on the complete flag (covers loaded AND + // failed). + // Every release path funnels through here EXCEPT _render's empty + // branch (the img is being cleared — nothing will ever settle). + _releaseMask(settled) { + if ( + !this._swapGen && + !this._loadPending && + (settled || this._img.complete) + ) { + this.removeAttribute('data-swapping'); + } + } + + // Baseline geometry, shared by clamp/apply/resize. `base` is the scale at + // view-scale s=1: cover = fill the frame (overflow on the looser axis), + // contain = fit fully inside (letterboxed). Zooming a contain image past + // s where it overflows naturally becomes a crop. Null until the img has + // loaded (naturalWidth is 0 before that) or when the slot has no layout + // box — ResizeObserver fires with a 0×0 rect under display:none, and + // clamping against a degenerate 1×1 frame would silently pull the stored + // pan toward zero. + _geom() { + const iw = this._img.naturalWidth, ih = this._img.naturalHeight; + const fw = this.clientWidth, fh = this.clientHeight; + if (!iw || !ih || !fw || !fh) return null; + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + const base = contain + ? Math.min(fw / iw, fh / ih) + : Math.max(fw / iw, fh / ih); + return { iw, ih, fw, fh, base }; + } + + _clampView() { + // Pan range on each axis is half the overflow past the frame edge. + const g = this._geom(); + if (!g) return; + const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50); + const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50); + this._view.x = Math.max(-mx, Math.min(mx, this._view.x)); + this._view.y = Math.max(-my, Math.min(my, this._view.y)); + } + + _applyView() { + const g = this._geom(); + // Top-layer controls: pin to the frame's top-right in viewport px + // (the same 8px inset as the in-frame layout; unscaled — top-layer UI + // reads as chrome, not page content). BEFORE the geometry branch: + // placement needs only the frame rect, and a not-yet-loaded or broken + // src must not leave the promoted strip floating unpositioned. Gated + // on the popover actually being open: without the Popover API, + // showPopover() threw (swallowed in _enterReframe), .ctl stays in + // its in-frame absolute layout, and viewport-px coordinates would + // shove it off-frame — and matches(':popover-open') itself throws + // there (unknown pseudo-class), hence the try/catch. + if (this.hasAttribute('data-reframe')) { + let onTop = false; + try { onTop = this._ctl.matches(':popover-open'); } catch {} + if (onTop) { + const r = this.getBoundingClientRect(); + this._ctl.style.left = (r.right - 8) + 'px'; + this._ctl.style.top = (r.top + 8) + 'px'; + } + } + if (!g) { + // Dimensions not known yet (before img load) — centered fit so there + // is no flash of an unpositioned image before the geometry lands. + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + this._img.style.width = '100%'; + this._img.style.height = '100%'; + this._img.style.left = '50%'; + this._img.style.top = '50%'; + this._img.style.objectFit = contain ? 'contain' : 'cover'; + return; + } + // Baseline (cover-fill or contain-fit) × view scale. Width/height and + // left/top are all frame-% — depends only on the frame aspect ratio, so + // a responsive resize keeps the same crop. The spill layer mirrors the + // same box so its corners = image corners. + const k = g.base * this._view.s; + const w = (g.iw * k / g.fw * 100) + '%'; + const h = (g.ih * k / g.fh * 100) + '%'; + const l = (50 + this._view.x) + '%'; + const t = (50 + this._view.y) + '%'; + this._img.style.width = w; this._img.style.height = h; + this._img.style.left = l; this._img.style.top = t; + this._img.style.objectFit = ''; + if (this.hasAttribute('data-reframe')) { + // Top-layer spill: position in viewport px over the frame. The top + // layer escapes ancestor transforms entirely, so EVERY term must be + // in viewport units: getBoundingClientRect gives the frame's scaled + // origin AND size, and the rect/layout ratio rescales the ghost — + // sizing from layout px alone renders it 1/scale too large under a + // scaled deck slide. Inner ghost + handles stay box-relative. + const r = this.getBoundingClientRect(); + const sx = g.fw ? r.width / g.fw : 1; + const sy = g.fh ? r.height / g.fh : 1; + this._spill.style.width = (g.iw * k * sx) + 'px'; + this._spill.style.height = (g.ih * k * sy) + 'px'; + this._spill.style.left = (r.left + (50 + this._view.x) / 100 * r.width) + 'px'; + this._spill.style.top = (r.top + (50 + this._view.y) / 100 * r.height) + 'px'; + } + } + + _commitView() { + const v = { s: this._view.s, x: this._view.x, y: this._view.y }; + if (this._userUrl) v.u = this._userUrl; + // Framing-only (no u) persists too so an author-src slot remembers its + // crop; clearing the sidecar still falls through to src=. + if (this.id) setSlot(this.id, v); + else { this._local = v; } + } + + _render() { + // Shape / mask. Presets use border-radius so the dashed ring can + // follow the rounded outline; clip-path is only applied for an + // explicit `mask` (the ring is hidden there since a rectangle + // dashed border chopped by an arbitrary polygon looks broken). + const mask = this.getAttribute('mask'); + const shape = (this.getAttribute('shape') || 'rounded').toLowerCase(); + let radius = ''; + if (shape === 'circle') radius = '50%'; + else if (shape === 'pill') radius = '9999px'; + else if (shape === 'rounded') { + const n = parseFloat(this.getAttribute('radius')); + radius = (Number.isFinite(n) ? n : 12) + 'px'; + } + this._frame.style.borderRadius = mask ? '' : radius; + this._frame.style.clipPath = mask || ''; + this._ring.style.borderRadius = mask ? '' : radius; + this._ring.style.display = mask ? 'none' : ''; + + // Controls and reframe entry gate on this so share links stay read-only. + const editable = !!(window.omelette && window.omelette.writeFile); + this.toggleAttribute('data-editable', editable); + this._sub.style.display = editable ? '' : 'none'; + + // Content. The sidecar is also writable by the agent's write_file + // tool, so its value isn't guaranteed canvas-originated — only accept + // data:image/ URLs from it. The `src` attribute is author-controlled + // (Claude wrote it into the HTML) so it passes through unchanged. + let stored = this.id ? getSlot(this.id) : this._local; + if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null; + const srcAttr = this.getAttribute('src') || ''; + this._userUrl = (stored && stored.u) || null; + const url = this._userUrl || srcAttr; + // Don't clobber an in-flight reframe with a store-triggered re-render. + if (!this.hasAttribute('data-reframe')) { + this._view = { + s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1, + x: stored && Number.isFinite(stored.x) ? stored.x : 0, + y: stored && Number.isFinite(stored.y) ? stored.y : 0, + }; + } + this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image'; + // Toggle via style.display — the [hidden] attribute alone loses to + // the display:flex / display:block rules in the stylesheet above. + // An Unsplash src with no credit attribute must NOT render — showing + // the photo uncredited is the Unsplash-terms violation itself. The + // error tile replaces the photo until the credit is written. A + // user-dropped image is the user's own content and always renders. + // Trimmed: credit is agent/user-editable content, and a whitespace- + // only value must count as missing — otherwise it would suppress the + // error tile AND render an empty credit box (no text, no links), + // exactly the unattributed state this gate exists to prevent. + const credit = (this.getAttribute('credit') || '').trim(); + const attrError = !!( + !credit && !this._userUrl && srcAttr && isUnsplashHost(srcAttr) + ); + this.toggleAttribute('data-attribution-error', attrError); + if (url && !attrError) { + const prev = this._img.getAttribute('src'); + if (prev !== url) { + // Replacing an already-shown image: mark the swap BEFORE setting + // src so the stale frame is never revealed (see the data-swapping + // stylesheet rules). First fill (prev empty) keeps the existing + // placeholder-until-load behavior — no spinner. _hidShowing + // covers the pick path's transient attribution-error wipe: prev + // is gone, but an image WAS showing, so this is a replacement. + if (prev || this._hidShowing) this.setAttribute('data-swapping', ''); + // Mark the swap BEFORE assigning src: complete keeps reporting + // the old settled request until the browser's + // update-the-image-data microtask runs, so same-task re-renders + // (the pick path's credit/credit-href setAttributes) need this + // flag, not complete, to know a load is in flight. + this._loadPending = true; + this._img.src = url; + this._ghost.src = url; + } else { + // Same-src re-render — release if settled, so an ingest-set + // spinner can't stick after a byte-identical re-upload (same + // data URL, no further load event ever fires). + this._releaseMask(); + } + this._hidShowing = false; + this._img.style.display = 'block'; + this._empty.style.display = 'none'; + this.setAttribute('data-filled', ''); + this._clampView(); + this._applyView(); + } else { + this.removeAttribute('data-swapping'); + // The src is being removed — no load/error will ever fire for it. + this._loadPending = false; + // A transient attribution-error wipe of a showing image happens on + // the pick path: the host sets src one setAttribute before credit, + // so render N hides the old image (attrError) and render N+1 + // restores a URL. Remember the wipe so that restore renders as a + // replacement (spinner), not a first fill (blank frame). + this._hidShowing = attrError && !!this._img.getAttribute('src'); + this._img.style.display = 'none'; + this._img.removeAttribute('src'); + this._ghost.removeAttribute('src'); + // The error tile owns the blocked-photo state; .empty stays for + // the genuinely-empty slot. + this._empty.style.display = attrError ? 'none' : 'flex'; + this.removeAttribute('data-filled'); + } + + // Credit belongs to the author src, so a user drop hides it. + // textContent + the http(s)-only funnel keep external strings inert. + const showCredit = !!(url && credit && !this._userUrl && !attrError); + this._credit.textContent = ''; + if (showCredit) { + // Validate once (resolved against the document, http(s) only), + // then append the terms-required utm referral params to links + // that point back at unsplash.com. + let href = ''; + const rawHref = this.getAttribute('credit-href') || ''; + if (rawHref) { + try { + const u = new URL(rawHref, document.baseURI); + if (u.protocol === 'http:' || u.protocol === 'https:') { + href = withReferral(u.href); + } + } catch {} + } + const mkLink = (text, linkHref) => { + const a = document.createElement('a'); + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); + a.setAttribute('href', linkHref); + a.textContent = text; + return a; + }; + // Unsplash's prescribed credit is TWO links — the photographer's + // name to their profile (credit-href) and 'Unsplash' to the + // homepage. Render that split whenever the text has the canonical + // shape; other text keeps the legacy single-link rendering. + const m = /^Photo by (.+) on Unsplash$/.exec(credit); + if (m) { + this._credit.appendChild(document.createTextNode('Photo by ')); + this._credit.appendChild( + href ? mkLink(m[1], href) : document.createTextNode(m[1]) + ); + this._credit.appendChild(document.createTextNode(' on ')); + this._credit.appendChild(mkLink('Unsplash', UNSPLASH_HOMEPAGE_HREF)); + } else if (href) { + this._credit.appendChild(mkLink(credit, href)); + } else { + this._credit.textContent = credit; + } + } + this.toggleAttribute('data-credit', showCredit); + } + } + + if (!customElements.get('image-slot')) { + customElements.define('image-slot', ImageSlot); + } +})(); diff --git a/design_handoff_rosterchief_platform/ios-frame.jsx b/design_handoff_rosterchief_platform/ios-frame.jsx new file mode 100644 index 0000000..d6f5686 --- /dev/null +++ b/design_handoff_rosterchief_platform/ios-frame.jsx @@ -0,0 +1,352 @@ +// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) +// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected). + +/* BEGIN USAGE */ +// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame +// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps. +// Exports (to window): IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard +// +// Usage — wrap your screen content in to get the bezel, status bar +// and home indicator (props: title, dark, keyboard): +// +// +// ...your screen content... +// +// +/* END USAGE */ + +// ───────────────────────────────────────────────────────────── +// Status bar +// ───────────────────────────────────────────────────────────── +function IOSStatusBar({ dark = false, time = '9:41' }) { + const c = dark ? '#fff' : '#000'; + return ( +
    +
    + {time} +
    +
    + + + + + + + + + + + + + + + + +
    +
    + ); +} + +// ───────────────────────────────────────────────────────────── +// Liquid glass pill — blur + tint + shine +// ───────────────────────────────────────────────────────────── +function IOSGlassPill({ children, dark = false, style = {} }) { + return ( +
    + {/* blur + tint */} +
    + {/* shine */} +
    +
    + {children} +
    +
    + ); +} + +// ───────────────────────────────────────────────────────────── +// Navigation bar — glass pills + large title +// ───────────────────────────────────────────────────────────── +function IOSNavBar({ title = 'Title', dark = false, trailingIcon = true }) { + const muted = dark ? 'rgba(255,255,255,0.6)' : '#404040'; + const text = dark ? '#fff' : '#000'; + const pillIcon = (content) => ( + +
    + {content} +
    +
    + ); + return ( +
    +
    + {/* back chevron */} + {pillIcon( + + + + )} + {/* trailing ellipsis */} + {trailingIcon && pillIcon( + + + + + + )} +
    + {/* large title */} +
    {title}
    +
    + ); +} + +// ───────────────────────────────────────────────────────────── +// Grouped list (inset card, r:26) + row (52px) +// ───────────────────────────────────────────────────────────── +function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) { + const text = dark ? '#fff' : '#000'; + const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)'; + const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)'; + const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)'; + return ( +
    + {icon && ( +
    + )} +
    {title}
    + {detail && {detail}} + {chevron && ( + + + + )} + {!isLast && ( +
    + )} +
    + ); +} + +function IOSList({ header, children, dark = false }) { + const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)'; + const bg = dark ? '#1C1C1E' : '#fff'; + return ( +
    + {header && ( +
    {header}
    + )} +
    {children}
    +
    + ); +} + +// ───────────────────────────────────────────────────────────── +// Device frame +// ───────────────────────────────────────────────────────────── +function IOSDevice({ + children, width = 402, height = 874, dark = false, + title, keyboard = false, +}) { + return ( + // data-om-starter: inert presence marker — Claude Design's starter-usage + // probe reads it; it renders nothing. Keep it on this root element. +
    + {/* dynamic island */} +
    + {/* status bar (absolute) */} +
    + +
    + {/* nav + content */} +
    + {title !== undefined && } +
    {children}
    + {keyboard && } +
    + {/* home indicator — always on top */} +
    +
    +
    +
    + ); +} + +// ───────────────────────────────────────────────────────────── +// Keyboard — iOS 26 liquid glass +// ───────────────────────────────────────────────────────────── +function IOSKeyboard({ dark = false }) { + const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959'; + const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333'; + const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)'; + + // special-key icons + const icons = { + shift: , + del: , + ret: , + }; + + const key = (content, { w, flex, ret, fs = 25, k } = {}) => ( +
    {content}
    + ); + + const row = (keys, pad = 0) => ( +
    + {keys.map(l => key(l, { flex: true, k: l }))} +
    + ); + + return ( +
    + {/* liquid glass bg — same recipe as nav pills */} +
    +
    + + {/* autocorrect bar */} +
    + {['"The"', 'the', 'to'].map((w, i) => ( + + {i > 0 &&
    } +
    {w}
    + + ))} +
    + + {/* key layout */} +
    + {row(['q','w','e','r','t','y','u','i','o','p'])} + {row(['a','s','d','f','g','h','j','k','l'], 20)} +
    + {key(icons.shift, { w: 45, k: 'shift' })} +
    + {['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))} +
    + {key(icons.del, { w: 45, k: 'del' })} +
    +
    + {key('ABC', { w: 92.25, fs: 18, k: 'abc' })} + {key('', { flex: true, k: 'space' })} + {key(icons.ret, { w: 92.25, ret: true, k: 'ret' })} +
    +
    + + {/* bottom spacer (emoji+mic area, icons omitted) */} +
    +
    + ); +} + +Object.assign(window, { + IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard, +}); diff --git a/design_handoff_rosterchief_platform/rosterchief-dark.svg b/design_handoff_rosterchief_platform/rosterchief-dark.svg new file mode 100644 index 0000000..d58f99f --- /dev/null +++ b/design_handoff_rosterchief_platform/rosterchief-dark.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/design_handoff_rosterchief_platform/support.js b/design_handoff_rosterchief_platform/support.js new file mode 100644 index 0000000..cb009b6 --- /dev/null +++ b/design_handoff_rosterchief_platform/support.js @@ -0,0 +1,1911 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/; + var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/; + var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/; + function isDeckMountTag(el) { + if (el.localName === "deck-stage") return true; + return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage"; + } + function walkDeckChildren(el, host) { + const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null); + const kids = pairs.map((p) => p.b); + const seen = /* @__PURE__ */ new Set(); + const wsSeen = /* @__PURE__ */ new Map(); + const keys = []; + const nextSlideId = new Array(pairs.length); + { + let upcoming = null; + for (let j = pairs.length - 1; j >= 0; j--) { + const n = pairs[j].c; + if (n.nodeType === Node.ELEMENT_NODE) { + const t = n.localName; + upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null; + } + nextSlideId[j] = upcoming; + } + } + for (let j = 0; j < pairs.length; j++) { + const { c } = pairs[j]; + if (c.nodeType === Node.TEXT_NODE) { + if ((c.nodeValue ?? "").trim() === "") { + const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux"; + const n = wsSeen.get(base) ?? 0; + wsSeen.set(base, n + 1); + keys.push(n === 0 ? base : base + ":" + n); + continue; + } + return { kids, keys: null }; + } + if (c.nodeType !== Node.ELEMENT_NODE) { + keys.push(j); + continue; + } + const child = c; + const tag = child.localName; + if (DECK_AUX_RE.test(tag)) { + keys.push(j); + continue; + } + if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null }; + const v = child.getAttribute("data-om-slide-id"); + if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) { + return { kids, keys: null }; + } + seen.add(v); + keys.push("omid:" + v); + } + return { kids, keys }; + } + function renderDeckKids(kids, kidKeys, vals, ctx) { + return kids.map((b, j) => { + const k = kidKeys ? kidKeys[j] : j; + const out = b(vals, ctx, k); + return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out; + }); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : []; + const kidKeys = deckKeyed?.keys ?? null; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) { + props.children = renderDeckKids(kids, kidKeys, vals, ctx); + } + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host); + const kidKeys = deckKeyed?.keys ?? null; + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx)); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function scanUnquotedUrl(css, i) { + if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) { + return -1; + } + let j = i + 4; + while (j < css.length && /\s/.test(css[j])) j++; + if (css[j] === '"' || css[j] === "'") return -1; + while (j < css.length && css[j] !== ")") { + if (css[j] === "\\") j++; + j++; + } + return j < css.length ? j + 1 : css.length; + } + function stripComments(css) { + let out = ""; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") { + out += c + (css[i + 1] ?? ""); + i++; + continue; + } + if (c === quote) quote = ""; + out += c; + } else if (c === "'" || c === '"') { + quote = c; + out += c; + } else if (c === "/" && css[i + 1] === "*") { + const end = css.indexOf("*/", i + 2); + i = end === -1 ? css.length : end + 1; + out += " "; + } else { + const end = scanUnquotedUrl(css, i); + if (end === -1) out += c; + else { + out += css.slice(i, end); + i = end - 1; + } + } + } + return out; + } + function importantify(css) { + css = stripComments(css); + const decls = []; + let start = 0; + let depth = 0; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = ""; + } else if (c === "'" || c === '"') quote = c; + else if (c === "(") depth++; + else if (c === ")") depth = Math.max(0, depth - 1); + else if (c === ";" && depth === 0) { + decls.push(css.slice(start, i)); + start = i + 1; + } else { + const end = scanUnquotedUrl(css, i); + if (end !== -1) i = end - 1; + } + } + decls.push(css.slice(start)); + return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";"); + } + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const isPseudoElement = pseudo === "before" || pseudo === "after"; + const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule( + sel + "{" + (isPseudoElement ? css : importantify(css)) + "}", + el.sheet.cssRules.length + ); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: + + {% block extra_body %}{% endblock extra_body %} + + diff --git a/management/templates/management/_family_members_table.html b/management/templates/management/_family_members_table.html index a22f161..8e5fbc7 100644 --- a/management/templates/management/_family_members_table.html +++ b/management/templates/management/_family_members_table.html @@ -9,59 +9,57 @@ management.views.group_by_family: family/guardians/children/others/all) and passes its own URL so admins land back on the member they were viewing; family_detail.html leaves it unset, since staying on the family page is already the right place there. {% endcomment %} -
    - - - - - - - - - - - {% for person in group.all %} - - - - + + {% endfor %} + +
    {% trans "Name" %}{% trans "Email" %}{% trans "Type" %}
    {{ person.last_name }}, {{ person.first_name }}{{ person.contact_email|default:"-" }} - {% if is_club_admin %} -
    - {% csrf_token %} - {% if next_url %}{% endif %} - -
    - {% else %} - {{ person.role_in_family_display|capfirst }} + + + + + + + + + + + {% for person in group.all %} + + + + + - - - {% endfor %} - -
    {% trans "Name" %}{% trans "Email" %}{% trans "Type" %}
    {{ person.last_name }}, {{ person.first_name }}{{ person.contact_email|default:"—" }} + {% if is_club_admin %} +
    + {% csrf_token %} + {% if next_url %}{% endif %} + +
    + {% else %} + {{ person.role_in_family_display|capfirst }} + {% endif %} +
    + {% if is_club_admin %} +
    + {% if person.grant_login_form %} + {% endif %} -
    - {% if is_club_admin %} -
    - {% if person.grant_login_form %} - - {% endif %} - {% lucide "pencil" size=14 %} {% trans "Edit" %} - - -
    - {% endif %} -
    - + {% lucide "pencil" size=13 %} {% trans "Edit" %} + + + + {% endif %} +
    {% if is_club_admin %} {% trans "Delete member" as delete_member_title %} diff --git a/management/templates/management/_generic_list.html b/management/templates/management/_generic_list.html index 044a665..a7970ff 100644 --- a/management/templates/management/_generic_list.html +++ b/management/templates/management/_generic_list.html @@ -1,5 +1,5 @@ {% extends "management/base.html" %} -{% load i18n %} +{% load i18n lucide %} {% comment %} Shared placeholder for every entity that doesn't have its own list template yet @@ -11,23 +11,26 @@ {% block heading %}{{ page_title }}{% endblock heading %} {% block panel %} -
    -
    -
    - - - {% for object in object_list %} - - - - {% empty %} - - - - {% endfor %} - -
    {{ object }}
    {% trans "Nothing here yet." %}
    -
    +
    + {% lucide "construction" size=18 %} + {% trans "This section is still being built. For now it only lists what's on file." %} +
    + +
    +
    + + + {% for object in object_list %} + + + + {% empty %} + + + + {% endfor %} + +
    {{ object }}
    {% trans "Nothing here yet." %}
    {% endblock panel %} diff --git a/management/templates/management/_group_bulk_add_row.html b/management/templates/management/_group_bulk_add_row.html index 20f7e5f..e8853e4 100644 --- a/management/templates/management/_group_bulk_add_row.html +++ b/management/templates/management/_group_bulk_add_row.html @@ -6,8 +6,8 @@ {{ form.member }} - {% for error in form.member.errors %}

    {{ error }}

    {% endfor %} - {% for error in form.non_field_errors %}

    {{ error }}

    {% endfor %} + {% for error in form.member.errors %}

    {{ error }}

    {% endfor %} + {% for error in form.non_field_errors %}

    {{ error }}

    {% endfor %} diff --git a/management/templates/management/_nav_items.html b/management/templates/management/_nav_items.html index fe50c55..4c35917 100644 --- a/management/templates/management/_nav_items.html +++ b/management/templates/management/_nav_items.html @@ -1,74 +1,109 @@ {% load i18n lucide %} {% comment %} - The management nav, in one place: the sidebar renders it on a wide screen and the - collapsed menu renders it on a narrow one. Admin-only sections are hidden here for - plain staff -- the views are gated regardless (ClubAdminRequiredMixin), this is - just so the nav never shows a link they can't follow. + The two-level sidebar (design_handoff_rosterchief_platform/README.md, D1/D2): seven + top-level sections -- Overview/Members/Teams/Calendar/News/Finance/Settings -- each + except News expanding into sub-items while it (or a sub-item) is active. `nav`/ + `nav_section` come from management.context_processors.active_nav_section. - `nav` (management.context_processors.active_nav_section) is the current page's - section, derived from the resolved URL name -- `menu-active` is daisyUI's active - state, same convention as controlpanel/templates/controlpanel/_nav_items.html. + Admin-only sub-items are hidden here for plain staff -- the views are gated + regardless (ClubAdminRequiredMixin), this is just so the nav never shows a link + they can't follow. Same reasoning as the old flat nav this replaces. {% endcomment %} -
  • - {% lucide "layout-dashboard" size=16 %} {% trans "Dashboard" %} -
  • +
    {% lucide "layout-dashboard" size=17 %} {% trans "Overview" %} - -
  • {% lucide "users" size=16 %} {% trans "Members" %}
  • -
  • {% lucide "home" size=16 %} {% trans "Families" %}
  • -{% if is_club_admin %} -
  • - - {% lucide "inbox" size=16 %} {% trans "Parent claims" %} - {{ pending_parent_claims_count }} - -
  • -{% endif %} -{% if is_club_admin %} -
  • {% lucide "wallet" size=16 %} {% trans "Memberships" %}
  • -
  • {% lucide "shield-check" size=16 %} {% trans "Roles" %}
  • -
  • {% lucide "users-round" size=16 %} {% trans "Groups" %}
  • -{% endif %} +
    + {% lucide "users" size=17 %} {% trans "Members" %} + {% if nav_section == 'members' %} +
    + {% trans "All members" %} + {% if is_club_admin %} + {# Sign-up is admin only (finance-adjacent -- fee status feeds it) even though parent claims/households/groups below open up to MEMBER_ADMIN too. #} + {% trans "Sign-up" %} + {% endif %} + {% if can_manage_members %} + {# Membership dues/fee tracking (management:membership_list) lives under Finance, not here -- see that section below. #} + + {% endif %} + {% trans "Households" %} + {% if can_manage_members %} + {% trans "Groups" %} + {% endif %} +
    + {% endif %} +
    - -
  • {% lucide "shirt" size=16 %} {% trans "Teams" %}
  • -
  • {% lucide "tags" size=16 %} {% trans "Positions" %}
  • -{% if is_club_admin %} -
  • {% lucide "badge-check" size=16 %} {% trans "Referee levels" %}
  • -
  • - - {% lucide "calendar-check" size=16 %} {% trans "Referee management" %} - {{ games_missing_referees_count }} - -
  • -{% endif %} -
  • {% lucide "flag" size=16 %} {% trans "Referees" %}
  • +
    + {% lucide "shirt" size=17 %} {% trans "Teams" %} + {% if nav_section == 'teams' %} + + {% endif %} +
    - -
  • {% lucide "newspaper" size=16 %} {% trans "News" %}
  • +
    + {% lucide "calendar" size=17 %} {% trans "Calendar" %} + {% if nav_section == 'calendar' %} +
    + {% trans "Events" %} + {% if has_management_position %} + {% trans "Locations" %} + {% trans "Opponents" %} + {% endif %} +
    + {% endif %} +
    - -
  • {% lucide "calendar" size=16 %} {% trans "Events" %}
  • -{% if has_management_position %} -
  • {% lucide "map-pin" size=16 %} {% trans "Locations" %}
  • -
  • {% lucide "swords" size=16 %} {% trans "Opponents" %}
  • -{% endif %} +{% lucide "newspaper" size=17 %} {% trans "News" %} {% if is_club_admin %} - -
  • {% lucide "handshake" size=16 %} {% trans "Sponsors" %}
  • +
    + {% lucide "wallet" size=17 %} {% trans "Finance" %} + {% if nav_section == 'finance' %} + + {% endif %} +
    {% endif %} -{% if is_club_admin and shop_enabled %} - -
  • {% lucide "package" size=16 %} {% trans "Products" %}
  • -
  • {% lucide "shopping-cart" size=16 %} {% trans "Orders" %}
  • -
  • {% lucide "percent" size=16 %} {% trans "Discounts" %}
  • -
  • {% lucide "receipt" size=16 %} {% trans "Invoices" %}
  • -{% endif %} - -{% if is_club_admin and forms_enabled %} - -
  • {% lucide "clipboard-list" size=16 %} {% trans "Forms" %}
  • +{% if is_club_admin or can_manage_members %} +
    + {# MEMBER_ADMIN has no Club identity access, so their landing click on "Settings" itself goes to the first sub-item they can actually reach. #} + {% lucide "settings" size=17 %} {% trans "Settings" %} + {% if nav_section == 'settings' %} + + {% endif %} +
    {% endif %} diff --git a/management/templates/management/_referee_assignment_panel.html b/management/templates/management/_referee_assignment_panel.html index c1fac14..55cfdfc 100644 --- a/management/templates/management/_referee_assignment_panel.html +++ b/management/templates/management/_referee_assignment_panel.html @@ -1,13 +1,20 @@ {% load i18n lucide ui %} {% comment %} Shared by the event detail page and the referee management dashboard -- - context: event, referees (EventReferee rows, each with a .fee_form when - can_manage_referees), referee_candidates, referees_full, - can_manage_referees, and an optional next_url to return to after a POST - (defaults to the event detail page when blank). + context: event, referees (EventReferee rows -- fee/km/km_rate read straight + off the model instance, no separate form object needed), referee_candidates, + referees_full, can_manage_referees, and an optional next_url to return to + after a POST (defaults to the event detail page when blank). + + The fee is entered right here, inline, rather than behind a "Fee" button + that opened a second (and on the dashboard, nested-inside-a-dialog) modal + -- that extra click-through was the easiest way for a fee to slip past + everyone's attention until PDF export time. Travel (km/rate) stays behind + a small disclosure since most assignments never need it, but posts in the + same form as the fee so there's still only one Save per referee. {% endcomment %}
    -

    +

    {% lucide "flag" size=18 %} {% trans "Referees" %} {{ referees|length }} / {{ event.max_referees }} @@ -17,40 +24,66 @@ {% lucide "file-down" size=12 %} {% trans "Referee form (PDF)" %} {% endif %}

    -
      +
        {% for referee in referees %} -
      • - - {{ referee.display_name }} - {% if referee.is_external %}{% trans "External" %}{% endif %} - {% if referee.assigned_by %}— {% blocktrans with name=referee.assigned_by %}assigned by {{ name }}{% endblocktrans %}{% endif %} - {% if referee.total_payable %}— {% blocktrans with total=referee.total_payable|floatformat:2 %}€{{ total }} due{% endblocktrans %}{% endif %} - +
      • +
        +
        + {{ referee.display_name }} + {% if referee.is_external %}{% trans "External" %}{% endif %} + {% if can_manage_referees and not referee.fee %}{% trans "Fee not set" %}{% endif %} +
        + {% if referee.assigned_by %}{% blocktrans with name=referee.assigned_by %}Assigned by {{ name }}{% endblocktrans %}{% endif %} +
        + {% if can_manage_referees %} -
        - +
        + {% url 'management:event_referee_fee_update' event.pk referee.pk as fee_action_url %} +
        + {% csrf_token %} + +
        + {% lucide "car" size=12 %} +
        + + +
        +
        + +
        + {% if referee.km %} + {% blocktrans with total=referee.total_payable|floatformat:2 %}{{ total }} due{% endblocktrans %} + {% endif %}
        {% csrf_token %} {% if next_url %}{% endif %} - +
        + {% elif referee.total_payable %} + {% blocktrans with total=referee.total_payable|floatformat:2 %}€{{ total }} due{% endblocktrans %} {% endif %}
      • {% empty %} -
      • {% trans "No referees assigned yet." %}
      • +
      • {% trans "No referees assigned yet." %}
      • {% endfor %}
      -{% if can_manage_referees %} - {% if referees_full %} -

      {% trans "This game already has its maximum number of referees." %}

      - {% else %} +{% if can_manage_referees and not referees_full %} +
      + {% trans "Add a referee" %} {% if referee_candidates %} -
      + {% csrf_token %} {% if next_url %}{% endif %} - {% for candidate in referee_candidates %}
      -

      {% trans "⚠ = also expected at another event around this time -- shown as a warning, not blocked." %}

      {% else %} -

      +

      {% trans "No eligible referees for this team yet." %} - {% trans "Link a referee level to this team." %} + {% trans "Link a referee level to this team." %}

      {% endif %} -
      + {% csrf_token %} {% if next_url %}{% endif %} - +
      - {% endif %} -{% endif %} -{% if can_manage_referees %} - {% trans "Referee fee" as fee_title %} - {% trans "Save" as save_label %} - {% with encoded_next=next_url|urlencode %} - {% for referee in referees %} - {% url 'management:event_referee_fee_update' event.pk referee.pk as fee_action_url %} - {% with fee_action_url=fee_action_url|add:"?next="|add:encoded_next %} - {% include "controlpanel/_modal_form.html" with modal_id=referee.pk|dom_id:"referee_fee_modal" title=fee_title form=referee.fee_form action_url=fee_action_url submit_label=save_label submit_icon="save" %} - {% endwith %} - {% endfor %} - {% endwith %} +

      {% trans "⚠ = also expected at another event around this time -- shown as a warning, not blocked." %}

      +
      +{% elif can_manage_referees and referees_full %} +

      {% trans "This game already has its maximum number of referees." %}

      {% endif %} diff --git a/management/templates/management/_team_bulk_add_row.html b/management/templates/management/_team_bulk_add_row.html index 542fc14..947eafc 100644 --- a/management/templates/management/_team_bulk_add_row.html +++ b/management/templates/management/_team_bulk_add_row.html @@ -5,28 +5,28 @@ row" button clones from -- so the two can never drift apart. {% endcomment %} - + {{ form.member }} - {% for error in form.member.errors %}

      {{ error }}

      {% endfor %} - {% for error in form.non_field_errors %}

      {{ error }}

      {% endfor %} + {% for error in form.member.errors %}

      {{ error }}

      {% endfor %} + {% for error in form.non_field_errors %}

      {{ error }}

      {% endfor %} - + {{ form.position }} - {% for error in form.position.errors %}

      {{ error }}

      {% endfor %} + {% for error in form.position.errors %}

      {{ error }}

      {% endfor %} - + {{ form.jersey_number }} - {% for error in form.jersey_number.errors %}

      {{ error }}

      {% endfor %} + {% for error in form.jersey_number.errors %}

      {{ error }}

      {% endfor %} - + {{ form.is_captain }} - {% for error in form.is_captain.errors %}

      {{ error }}

      {% endfor %} + {% for error in form.is_captain.errors %}

      {{ error }}

      {% endfor %} - + {{ form.is_alternate_captain }} - {% for error in form.is_alternate_captain.errors %}

      {{ error }}

      {% endfor %} + {% for error in form.is_alternate_captain.errors %}

      {{ error }}

      {% endfor %} - - + + diff --git a/management/templates/management/base.html b/management/templates/management/base.html index a21abf2..7291f32 100644 --- a/management/templates/management/base.html +++ b/management/templates/management/base.html @@ -1,90 +1,169 @@ -{% extends "_club_base.html" %} -{% load i18n lucide %} +{% load static lucide ui i18n %} {% comment %} - The club-staff shell: team managers, coaches and admins only (never parents or - players -- see club.mixins.ClubStaffRequiredMixin). Mirrors controlpanel/base.html's - block structure, extending the club's own skin instead of the platform's. + Standalone shell for club management -- does NOT extend templates/_base.html or + _club_base.html, and does not load assets/app.css or daisyUI. Same reasoning as + controlpanel/templates/controlpanel/base.html's own standalone shell, but this + surface IS club-branded (design_handoff_rosterchief_platform/README.md, "Club + theming"): the --tenant-club/--tenant-club-dark custom properties below feed + assets/management.css's --color-club/--color-club-dark tokens, set per request + from Club.secondary_color. Desktop only, matching the design's own scope for this + surface -- no mobile nav here, unlike the club-facing chrome this replaces. {% endcomment %} + + + + + -{% block head_title %} - {% block section_title %}{% trans "Management" %}{% endblock section_title %} -{% endblock head_title %} + + {% block title %}{% block panel_title %}Management{% endblock panel_title %} · {{ club.name }}{% endblock title %} + -{% block nav_toggle %} - -{% endblock nav_toggle %} + + {% if club.secondary_color %} + + {% endif %} + {% if club.primary_color %} + + {% endif %} + {% block extra_head %}{% endblock extra_head %} + -{% comment %} Kept in the navbar itself only at `lg`+, where there's no hamburger drawer to hold them instead -- see the comment on `nav_icons_class` in _base.html. {% endcomment %} -{% block nav_icons_class %}hidden items-center lg:flex{% endblock nav_icons_class %} + +
      + + +
      +
      + {% block heading %}{% block topbar_title %}Management{% endblock topbar_title %}{% endblock heading %} + {% block topbar_context %}{% endblock topbar_context %} +
      +
      + {% block actions %}{% endblock actions %} +
      +
      + + {% block filter_strip %}{% endblock filter_strip %} + +
      +
      + {% if billing_notice.is_urgent and nav != "home" %} + {% include "management/_billing_notice.html" %} + {% endif %} + + {% if messages %} +
      + {% for message in messages %} + {% with alert=message|as_alert %} + + {% endwith %} + {% endfor %} +
      + {% endif %} + + {% block panel %}{% endblock panel %} +
      +
      - -
      - - - - {# flex-col below `sm`: title over a full-width, stacked column of action buttons reads far better on a phone than the same row wrapping mid-button. #} -
      -
      -

      - {% block heading %}{% trans "Management" %}{% endblock heading %} -

      - {% block subheading %}{% endblock subheading %}
      -
      - {% block actions %}{% endblock actions %} -
      -
      + - {% block panel %}{% endblock panel %} -{% endblock main %} + {% block extra_body %}{% endblock extra_body %} + + diff --git a/management/templates/management/club_settings.html b/management/templates/management/club_settings.html new file mode 100644 index 0000000..c00275b --- /dev/null +++ b/management/templates/management/club_settings.html @@ -0,0 +1,76 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% comment %} + A club's own self-service identity/branding editor -- see + management/forms.py's ClubSettingsForm for exactly which fields are + editable here versus platform-staff-only (slug, seasons). D9 "Club + identity & branding" in the design handoff is the reference; its + live-preview and advanced (custom stylesheet / own domain) panels aren't + real features here, so this sticks to the fields the form actually has, + laid out in the same card-per-section language. +{% endcomment %} + +{% block panel_title %}{% trans "Club identity" %}{% endblock panel_title %} +{% block heading %}{% trans "Club identity" %}{% endblock heading %} + +{% block actions %} + +{% endblock actions %} + +{% block panel %} +
      + {% csrf_token %} + + {% for error in form.non_field_errors %} +
      + {% lucide "circle-x" size=18 %} + {{ error }} +
      + {% endfor %} + +
      +
      +
      +
      +
      {% lucide "building-2" size=16 %} {% trans "Club" %}
      +
      + {% form_field form.name %} + {% form_field form.legal_name %} + {% form_field form.contact_email %} +
      +
      +
      + +
      +
      +
      {% lucide "image" size=16 %} {% trans "Logo" %}
      +
      + {% if club.logo %} + + {% else %} + {{ club.initials }} + {% endif %} +
      {% form_field form.logo %}
      +
      +
      +
      +
      + +
      +
      +
      {% lucide "palette" size=16 %} {% trans "Colours" %}
      +
      + {% form_field form.primary_color %} + {% form_field form.secondary_color %} +
      +
      + + + {% trans "Current colours" %} +
      +
      +
      +
      +
      +{% endblock panel %} diff --git a/management/templates/management/event_detail.html b/management/templates/management/event_detail.html index 835da4b..dce5c99 100644 --- a/management/templates/management/event_detail.html +++ b/management/templates/management/event_detail.html @@ -2,7 +2,13 @@ {% load i18n lucide %} {% block heading %}{{ event.title }}{% endblock heading %} -{% block subheading %}{{ event.get_kind_display }}{% endblock subheading %} + +{% block topbar_context %} + + {{ event.get_kind_display }} + + {% if event.is_live %}{% lucide "circle" size=10 %} {% trans "Live" %}{% endif %} +{% endblock topbar_context %} {% block actions %} {% if can_manage %} @@ -22,7 +28,7 @@ {% block panel %} {% if event.series_id %} -
      +
      {% lucide "repeat" size=20 %} {% blocktrans with series=event.series %}Part of the recurring series “{{ series }}”.{% endblocktrans %} @@ -32,54 +38,54 @@
      {% endif %} -
      -
      +
      +

      {% lucide "info" size=18 %} {% trans "Details" %}

      -
      -
      -
      {% trans "Start" %}
      -
      {{ event.start|date:"j M Y H:i" }}
      +
      +
      +
      {% trans "Start" %}
      +
      {{ event.start|date:"j M Y H:i" }}
      -
      -
      {% trans "End" %}
      -
      {{ event.end|date:"j M Y H:i"|default:"—" }}
      +
      +
      {% trans "End" %}
      +
      {{ event.end|date:"j M Y H:i"|default:"—" }}
      -
      -
      {% trans "Gathering" %}
      -
      {{ event.gathering|date:"j M Y H:i"|default:"—" }}
      +
      +
      {% trans "Gathering" %}
      +
      {{ event.gathering|date:"j M Y H:i"|default:"—" }}
      -
      -
      {% trans "Registration deadline" %}
      -
      {{ event.deadline|date:"j M Y H:i"|default:"—" }}
      +
      +
      {% trans "Registration deadline" %}
      +
      {{ event.deadline|date:"j M Y H:i"|default:"—" }}
      -
      -
      {% trans "Location" %}
      -
      {{ event.location|default:"—" }}
      +
      +
      {% trans "Location" %}
      +
      {{ event.location|default:"—" }}
      -
      -
      {% trans "Opponent" %}
      -
      {{ event.opponent|default:"—" }}
      +
      +
      {% trans "Opponent" %}
      +
      {{ event.opponent|default:"—" }}
      -
      -
      {% trans "Teams" %}
      -
      {% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
      +
      +
      {% trans "Teams" %}
      +
      {% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
      -
      -
      {% trans "Groups" %}
      -
      {% for group in event.groups.all %}{{ group.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
      +
      +
      {% trans "Groups" %}
      +
      {% for group in event.groups.all %}{{ group.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
      {% if event.club_wide %} -
      -
      {% trans "Audience" %}
      -
      {% trans "Whole club" %}
      +
      +
      {% trans "Audience" %}
      +
      {% trans "Whole club" %}
      {% endif %}
      -
      +

      {% lucide "user-check" size=18 %} {% trans "RSVPs" %}

      @@ -89,29 +95,26 @@ {% endif %}
      -
      +
      {% for group in attendance_groups %} -
      -
      {{ group.label }}
      -
      {{ group.rows|length }}
      +
      +
      {{ group.label }}
      +
      {{ group.rows|length }}
      {% endfor %}
      {% if not has_attendance_rows %} -

      {% trans "No one invited yet." %}

      +

      {% trans "No one invited yet." %}

      {% endif %}
      {% if event.kind == "game" %} -
      +
      -

      - {% lucide "trophy" size=18 %} {% trans "Game" %} - {% if event.is_live %}{% lucide "circle" size=10 %} {% trans "Live" %}{% endif %} -

      +

      {% lucide "trophy" size=18 %} {% trans "Game" %}

      {% if can_manage and event.competition %}
      {% csrf_token %} @@ -119,20 +122,20 @@
      {% endif %}
      -
      +
      -
      {% trans "Score" %}
      -
      +
      {% trans "Score" %}
      +
      {% if event.score_for is not None and event.score_against is not None %}{{ event.score_for }} - {{ event.score_against }}{% else %}—{% endif %}
      -
      {% trans "Competition" %}
      -
      {{ event.competition|default:"—" }}
      +
      {% trans "Competition" %}
      +
      {{ event.competition|default:"—" }}
      -
      {% trans "External game ID" %}
      -
      {{ event.external_game_id|default:"—" }}
      +
      {% trans "External game ID" %}
      +
      {{ event.external_game_id|default:"—" }}
      @@ -140,14 +143,14 @@ {% endif %} {% if event.is_home_game and not referee_management_needed %} -
      +
      {% lucide "info" size=20 %} {% trans "Referees for this game are managed by the federation, not the club." %}
      {% endif %} {% if referee_management_needed %} -
      +
      {% include "management/_referee_assignment_panel.html" %}
      @@ -159,19 +162,19 @@ {% if can_manage %}