Add Club.website; rebuild Colours per D9's exact markup; add a mobile app preview

- club.website (URLField, blank) editable from both the club's own Identity
  page and the platform control panel's club form.
- Colours card rebuilt from the design canvas's literal D9 markup rather
  than the prior in-between version: swatch + mono hex merged into one
  bordered row (the swatch input and hex text input are the same real form
  field, just laid out together), and a contrast-check row of two colour-
  filled boxes instead of a plain text list -- labelled dynamically
  ("Black"/"White" on primary/secondary) since a club can pick a colour
  light enough that black, not white, is the real computed text colour.
- Logo card restyled to D9's dashed drop-zone look.
- Live preview gains a mobile app mock (marked "Coming soon") alongside the
  existing sidebar mock, using the same primary/secondary colours, ahead of
  that surface actually being built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-20 09:13:03 +02:00
parent a0226d51a9
commit 59d1fce674
9 changed files with 300 additions and 68 deletions

View File

@@ -1737,23 +1737,73 @@
}
}
/* --- Club identity live preview (management/templates/management/club_settings.html) ---
A mini mock of this app's own sidebar + a sample button -- genuinely what
primary_color/secondary_color affect (base.html's --tenant-* custom
properties), not a fabricated App/Website/Email mockup. Colours are set
inline by that template's own script as the form fields change; only
layout/typography lives here. */
/* --- Club identity page (management/templates/management/club_settings.html) ---
Colours: swatch + mono hex merged into one bordered row (D9's own look),
not a labelled field with a decorative swatch floating below it. Live
preview: a mini mock of this app's own sidebar/button (the real, live
surface primary_color/secondary_color affect today, via base.html's
--tenant-* custom properties) plus a mobile app mock (the same colours
applied to the surface that's built next) -- not D9's fabricated App/
Website/Email tabs. Colours are set inline by that template's own script
as the form fields change; only layout/typography lives here. */
@layer components {
.club-color-row {
display: flex;
align-items: center;
gap: 0.625rem;
border: 1px solid var(--color-edge);
border-radius: 0.5rem;
padding: 0.5rem 0.625rem;
}
.club-color-swatch {
width: 2rem;
height: 1.5rem;
width: 1.625rem;
height: 1.625rem;
flex-shrink: 0;
padding: 0;
border: 1px solid var(--color-line);
border: none;
border-radius: 0.375rem;
background: none;
cursor: pointer;
}
.club-color-hex-input {
flex: 1;
min-width: 0;
border: none;
background: none;
outline: none;
padding: 0;
font-family: var(--font-mono);
font-size: 0.875rem;
color: var(--color-ink);
}
.club-contrast-box {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-radius: 0.5rem;
padding: 0.625rem 0.75rem;
font-size: 0.8125rem;
transition: background-color 0.15s ease, color 0.15s ease;
}
.club-logo-dropzone {
display: flex;
width: 4.75rem;
height: 4.75rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1.5px dashed var(--color-stroke);
border-radius: 0.5rem;
background: var(--color-paper);
}
.preview-frame {
display: flex;
height: 220px;
@@ -1856,10 +1906,119 @@
width: 60%;
}
.contrast-icon {
display: inline-flex;
width: 1rem;
/* The mobile app mock -- not built yet, but its header/role-switcher take
the same primary/secondary colours the sidebar mock above does, so this
preview stays accurate once it exists rather than needing a rework. */
.preview-phone {
width: 11rem;
flex-shrink: 0;
overflow: hidden;
border: 5px solid var(--color-ink);
border-radius: 1.375rem;
background: var(--color-paper);
}
.preview-phone-header {
padding: 0.75rem 0.625rem 0.625rem;
background: var(--color-ink);
color: #fff;
transition: background-color 0.15s ease, color 0.15s ease;
}
.preview-phone-header-row {
display: flex;
align-items: center;
gap: 0.375rem;
}
.preview-crest-sm {
width: 1.125rem;
height: 1.125rem;
font-size: 0.5rem;
border-radius: 0.25rem;
}
.preview-phone-name {
font-family: var(--font-display);
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.preview-phone-roles {
display: flex;
gap: 2px;
margin-top: 0.5rem;
padding: 2px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
}
.preview-phone-role {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
height: 1.25rem;
border-radius: 999px;
font-family: var(--font-display);
font-size: 0.5625rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-on-dark);
}
.preview-phone-role-active {
background: #fff;
color: var(--color-ink);
}
.preview-phone-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.625rem;
}
.preview-phone-card {
border-radius: 0.5rem;
padding: 0.5rem;
background: var(--color-club);
color: #fff;
transition: background-color 0.15s ease, color 0.15s ease;
}
.preview-phone-eyebrow {
font-family: var(--font-display);
font-size: 0.5625rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-info);
}
.preview-phone-title {
margin-top: 0.125rem;
font-family: var(--font-display);
font-size: 0.75rem;
line-height: 1.05;
font-weight: 800;
text-transform: uppercase;
}
.preview-phone-bar {
height: 1rem;
margin-top: 0.5rem;
border-radius: 0.25rem;
background: var(--color-ok);
}
.preview-phone-skeleton-card {
border: 1px solid var(--color-line);
border-radius: 0.5rem;
padding: 0.5rem;
background: #fff;
}
}

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-20 07:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0026_memberrequirementstatus_is_bypassed_and_more'),
]
operations = [
migrations.AddField(
model_name='club',
name='website',
field=models.URLField(blank=True, help_text="The club's own site, if it has one -- shown alongside its RosterChief pages, not used for anything else yet.", verbose_name='website'),
),
]

View File

@@ -48,6 +48,7 @@ class Club(UUIDModel):
blank=True,
help_text=_("The club's public address, shown to people the club writes to or asks to get in touch -- e.g. a parent claiming a child. Falls back to nothing being shown at all, so it's worth setting."),
)
website = models.URLField(_("website"), blank=True, help_text=_("The club's own site, if it has one -- shown alongside its RosterChief pages, not used for anything else yet."))
logo = models.FileField(
_("logo"),

View File

@@ -14,7 +14,7 @@ from .services.admins import find_member_by_email
class ClubForm(forms.ModelForm):
class Meta:
model = Club
fields = ["name", "legal_name", "contact_email", "slug", "sport_type", "logo", "primary_color", "secondary_color", "season_start", "season_duration_months"]
fields = ["name", "legal_name", "contact_email", "website", "slug", "sport_type", "logo", "primary_color", "secondary_color", "season_start", "season_duration_months"]
help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
# Deliberately a text input, not <input type="color">: a colour picker cannot
# express "no colour" -- it would submit #000000 for every club that never

View File

@@ -27,6 +27,7 @@
{% form_field form.name %}
{% form_field form.legal_name %}
{% form_field form.contact_email %}
{% form_field form.website %}
{% form_field form.slug %}
{% form_field form.sport_type %}
</div>

View File

@@ -881,7 +881,7 @@ class ClubSettingsForm(forms.ModelForm):
class Meta:
model = Club
fields = ["name", "legal_name", "contact_email", "logo", "primary_color", "secondary_color"]
fields = ["name", "legal_name", "contact_email", "website", "logo", "primary_color", "secondary_color"]
widgets = {
"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}),
"secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}),

View File

@@ -5,16 +5,18 @@
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: two columns,
form fields on the left and a live preview on the right. The preview here
is scoped to what actually exists in this app rather than D9's aspirational
App/Website/Email tabs -- primary_color drives the sidebar
(--tenant-sidebar-bg/fg, base.html), secondary_color drives the club accent
(--tenant-club/-dark/-content, buttons/active nav/crest) -- so the mini
sidebar + button mock below is genuinely what changing these fields does,
not a fabricated mockup. The colour math (content colour, contrast ratio)
mirrors Club._content_color_for exactly, so the "computed ratio" shown is
the real number the server will use, not an approximation.
identity & branding" in the design handoff is the reference, followed
closely for the Colours card (merged swatch+hex row, side-by-side
contrast-check boxes) and the Logo card (dashed drop zone) -- markup
pulled straight from the design canvas (RosterChief Platform.dc.html),
not the prose summary. The live preview is scoped to surfaces that are
(or will genuinely be) this app rather than D9's aspirational App/
Website/Email tabs: the sidebar mock is what primary_color/secondary_color
affect today (base.html's --tenant-* custom properties); the phone mock
is the same colours applied ahead of the mobile app being built. The
colour math (content colour, contrast ratio) mirrors
Club._content_color_for exactly, so what's shown is the real number the
server computes on save, not an approximation.
{% endcomment %}
{% block panel_title %}{% trans "Club identity" %}{% endblock panel_title %}
@@ -39,10 +41,11 @@
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "building-2" size=16 %} {% trans "Club" %}</div>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.name %}
{% form_field form.legal_name %}
{% form_field form.contact_email %}
{% form_field form.website %}
</div>
</div>
</div>
@@ -53,37 +56,37 @@
<div class="card-title">{% lucide "palette" size=16 %} {% trans "Colours" %}</div>
<span class="font-mono text-xs text-dim">{% trans "Accents only" %}</span>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
{% form_field form.primary_color %}
<div class="mt-1.5 flex items-center gap-2">
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{{ form.primary_color.label|capfirst }}</div>
<label class="club-color-row">
<input type="color" id="primary_color_swatch" class="club-color-swatch" value="{{ form.primary_color.value|default:"#0b1220" }}" aria-label="{% trans "Pick the primary colour" %}">
<span class="font-mono text-xs text-muted">{% trans "Sidebar background" %}</span>
</div>
<input type="text" name="{{ form.primary_color.html_name }}" id="{{ form.primary_color.id_for_label }}" value="{{ form.primary_color.value|default:"" }}" placeholder="#0b1220" class="club-color-hex-input">
</label>
{% if form.primary_color.errors %}<p class="mt-1 text-xs text-club-dark">{{ form.primary_color.errors|join:" " }}</p>{% endif %}
</div>
<div>
{% form_field form.secondary_color %}
<div class="mt-1.5 flex items-center gap-2">
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{{ form.secondary_color.label|capfirst }}</div>
<label class="club-color-row">
<input type="color" id="secondary_color_swatch" class="club-color-swatch" value="{{ form.secondary_color.value|default:"#e4002b" }}" aria-label="{% trans "Pick the secondary colour" %}">
<span class="font-mono text-xs text-muted">{% trans "Buttons & accents" %}</span>
</div>
<input type="text" name="{{ form.secondary_color.html_name }}" id="{{ form.secondary_color.id_for_label }}" value="{{ form.secondary_color.value|default:"" }}" placeholder="#e4002b" class="club-color-hex-input">
</label>
{% if form.secondary_color.errors %}<p class="mt-1 text-xs text-club-dark">{{ form.secondary_color.errors|join:" " }}</p>{% endif %}
</div>
</div>
<div class="mt-3 flex flex-col gap-1.5 border-t border-rule pt-3">
<div class="contrast-row flex items-center justify-between gap-2 text-xs">
<span class="text-muted">{% trans "Sidebar text on sidebar background" %}</span>
<span class="flex items-center gap-1 font-mono">
<span id="primary-contrast-ratio"></span>
<span class="contrast-icon"></span>
</span>
</div>
<div class="contrast-row flex items-center justify-between gap-2 text-xs">
<span class="text-muted">{% trans "Button text on accent colour" %}</span>
<span class="flex items-center gap-1 font-mono">
<span id="secondary-contrast-ratio"></span>
<span class="contrast-icon"></span>
</span>
<div class="mt-4">
<div class="mb-2 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{% trans "Contrast check" %}</div>
<div class="flex gap-2">
<div id="contrast-box-primary" class="club-contrast-box">
<span id="contrast-label-primary"></span>
<span id="contrast-ratio-primary" class="font-mono"></span>
</div>
<div id="contrast-box-secondary" class="club-contrast-box">
<span id="contrast-label-secondary"></span>
<span id="contrast-ratio-secondary" class="font-mono"></span>
</div>
</div>
</div>
</div>
@@ -92,12 +95,17 @@
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "image" size=16 %} {% trans "Logo" %}</div>
<div class="flex items-center gap-3.5">
{% if club.logo %}
<img class="h-14 w-14 shrink-0 rounded-lg border border-line bg-paper object-contain p-1" src="{{ club.logo.url }}" alt="">
{% else %}
<span class="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border border-line bg-paper font-display text-sm font-extrabold text-muted">{{ club.initials }}</span>
{% endif %}
<div class="flex items-end gap-4">
<div>
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{% trans "Crest" %}</div>
<div class="club-logo-dropzone">
{% if club.logo %}
<img class="h-full w-full object-contain p-1.5" src="{{ club.logo.url }}" alt="">
{% else %}
<span class="font-display text-sm font-extrabold text-muted">{{ club.initials }}</span>
{% endif %}
</div>
</div>
<div class="flex-1">{% form_field form.logo %}</div>
</div>
</div>
@@ -108,9 +116,10 @@
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "eye" size=16 %} {% trans "Live preview" %}</div>
<p class="text-xs text-muted">{% trans "Where these colours actually show up in the management app -- updates as you edit, before you save anything." %}</p>
<p class="text-xs text-muted">{% trans "Where these colours actually show up -- updates as you edit, before you save anything." %}</p>
<div class="preview-frame mt-2">
<div class="mt-2 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{% trans "Management app" %}</div>
<div class="preview-frame mt-1.5">
<div id="preview-sidebar" class="preview-sidebar">
<div class="preview-crest">{{ club.initials|default:"CL" }}</div>
<div class="preview-club-name">{{ club.name|default:_("Your club") }}</div>
@@ -126,6 +135,34 @@
</div>
</div>
<div class="mt-4 flex items-center justify-between gap-2">
<div class="font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{% trans "Mobile app" %}</div>
<span class="badge badge-sm badge-ghost">{% trans "Coming soon" %}</span>
</div>
<div class="mt-1.5 preview-phone">
<div id="preview-phone-header" class="preview-phone-header">
<div class="preview-phone-header-row">
<span class="preview-crest preview-crest-sm">{{ club.initials|default:"CL" }}</span>
<span class="preview-phone-name">{{ club.name|default:_("Your club") }}</span>
</div>
<div class="preview-phone-roles">
<span class="preview-phone-role preview-phone-role-active">{% trans "Member" %}</span>
<span class="preview-phone-role">{% trans "Coach" %}</span>
</div>
</div>
<div class="preview-phone-body">
<div id="preview-phone-card" class="preview-phone-card">
<div class="preview-phone-eyebrow">{% trans "Next up" %}</div>
<div class="preview-phone-title">{% trans "U16 vs Leuven" %}</div>
<div class="preview-phone-bar"></div>
</div>
<div class="preview-phone-skeleton-card">
<div class="preview-skeleton-line"></div>
<div class="preview-skeleton-line preview-skeleton-line-short"></div>
</div>
</div>
</div>
<div class="mt-3 text-xs text-dim">
{% trans "Never used for: status colours (paid/overdue, present/absent), table styling, or form fields -- those stay consistent for everyone regardless of club branding." %}
</div>
@@ -164,15 +201,15 @@
const secondarySwatch = document.getElementById("secondary_color_swatch");
if (!primaryText || !secondaryText) return;
const setContrastRow = (elementId, hex, contentHex) => {
const CONTENT_LABEL = { "#000000": "{% trans 'Black' %}", "#ffffff": "{% trans 'White' %}" };
const setContrastBox = (boxId, labelId, ratioId, hex, contentHex, onLabel) => {
const ratio = contrastRatio(hex, contentHex);
const cell = document.getElementById(elementId);
cell.textContent = `${ratio.toFixed(1)}:1`;
const row = cell.closest(".contrast-row");
const icon = row.querySelector(".contrast-icon");
icon.textContent = ratio >= 4.5 ? "✓" : "⚠";
icon.classList.toggle("text-ok", ratio >= 4.5);
icon.classList.toggle("text-warn", ratio < 4.5);
document.getElementById(labelId).textContent = `${CONTENT_LABEL[contentHex]} ${onLabel}`;
document.getElementById(ratioId).textContent = `${ratio.toFixed(1)} ${ratio >= 4.5 ? "✓" : "⚠"}`;
const box = document.getElementById(boxId);
box.style.background = hex;
box.style.color = contentHex;
};
const updatePreview = () => {
@@ -188,8 +225,13 @@
document.getElementById("preview-button").style.background = secondary;
document.getElementById("preview-button").style.color = secondaryContent;
setContrastRow("primary-contrast-ratio", primary, primaryContent);
setContrastRow("secondary-contrast-ratio", secondary, secondaryContent);
document.getElementById("preview-phone-header").style.background = primary;
document.getElementById("preview-phone-header").style.color = primaryContent;
document.getElementById("preview-phone-card").style.background = secondary;
document.getElementById("preview-phone-card").style.color = secondaryContent;
setContrastBox("contrast-box-primary", "contrast-label-primary", "contrast-ratio-primary", primary, primaryContent, "{% trans 'on primary' %}");
setContrastBox("contrast-box-secondary", "contrast-label-secondary", "contrast-ratio-secondary", secondary, secondaryContent, "{% trans 'on secondary' %}");
if (primarySwatch && isHex(primary)) primarySwatch.value = primary;
if (secondarySwatch && isHex(secondary)) secondarySwatch.value = secondary;

View File

@@ -4472,16 +4472,27 @@ class ClubSettingsPreviewTests(ManagementTestBase):
self.assertContains(response, 'id="primary_color_swatch"')
self.assertContains(response, 'id="secondary_color_swatch"')
def test_the_mobile_app_preview_renders(self):
response = self.club_get("club_settings")
self.assertContains(response, "preview-phone")
self.assertContains(response, "Coming soon")
def test_the_website_field_is_editable(self):
response = self.club_get("club_settings")
self.assertContains(response, 'name="website"')
def test_saving_still_updates_the_club(self):
response = self.club_post(
"club_settings",
{"name": "Ajax United", "legal_name": "", "contact_email": "", "primary_color": "#123456", "secondary_color": "#654321"},
{"name": "Ajax United", "legal_name": "", "contact_email": "", "website": "https://ajax-united.example", "primary_color": "#123456", "secondary_color": "#654321"},
)
self.assertRedirects(response, reverse("management:club_settings"))
self.club.refresh_from_db()
self.assertEqual(self.club.primary_color, "#123456")
self.assertEqual(self.club.secondary_color, "#654321")
self.assertEqual(self.club.website, "https://ajax-united.example")
class BillingEndingBannerTests(ManagementTestBase):

File diff suppressed because one or more lines are too long