feat(network): add responsive sponsor hierarchy
This commit is contained in:
@@ -32,7 +32,7 @@ This inventory records the frontend or platform owner for every retained `Pages`
|
|||||||
| `pressIndex.hero.*`, `pressIndex.events.*`, `pressIndex.news.*`, `pressIndex.downloads.*` | `src/spa/pages/Press.tsx`; event/article records remain collection-owned, while missing-field labels and images are presentation fallbacks only. `pressIndex.downloads.contactImage` and `contactImageAlt` own the optional Presse contact visual; an empty relationship renders the same copy in a compact copy-only layout without a static image fallback. |
|
| `pressIndex.hero.*`, `pressIndex.events.*`, `pressIndex.news.*`, `pressIndex.downloads.*` | `src/spa/pages/Press.tsx`; event/article records remain collection-owned, while missing-field labels and images are presentation fallbacks only. `pressIndex.downloads.contactImage` and `contactImageAlt` own the optional Presse contact visual; an empty relationship renders the same copy in a compact copy-only layout without a static image fallback. |
|
||||||
| `netzwerk.hero.*`, `netzwerk.patronage.*`, `netzwerk.juryIntro.*`, `netzwerk.expertise.*`, `netzwerk.sponsoring.*` | `src/spa/pages/Netzwerk.tsx`. The page-owned `netzwerk.patronage.greetingVideo` Media relationship and its adjacent button/modal copy control only the Ilse Aigner greeting on `/netzwerk`; they never inherit `home.videoModal.*`. |
|
| `netzwerk.hero.*`, `netzwerk.patronage.*`, `netzwerk.juryIntro.*`, `netzwerk.expertise.*`, `netzwerk.sponsoring.*` | `src/spa/pages/Netzwerk.tsx`. The page-owned `netzwerk.patronage.greetingVideo` Media relationship and its adjacent button/modal copy control only the Ilse Aigner greeting on `/netzwerk`; they never inherit `home.videoModal.*`. |
|
||||||
| `netzwerk.jury.{eyebrow,heading,description,chairBadge,members}` | Jury heading and authoritative rendered relationship selection in `src/spa/pages/Netzwerk.tsx`. |
|
| `netzwerk.jury.{eyebrow,heading,description,chairBadge,members}` | Jury heading and authoritative rendered relationship selection in `src/spa/pages/Netzwerk.tsx`. |
|
||||||
| `netzwerk.partners.{eyebrow,heading,description,detailLabel,tiers,modal}` | Partner section, tier labels, and modal copy in `src/spa/pages/Netzwerk.tsx`; partner records remain collection-owned. |
|
| `netzwerk.partners.{eyebrow,heading,description,detailLabel,tiers[].{label,note},modal}` | All visible sponsor-section editorial copy, including each rendered tier heading/note and the shared card CTA, in `src/spa/pages/Netzwerk.tsx`; partner names, descriptions, links, logos, tier assignment, and ordering remain Partner collection-owned. Counts are derived and are not separately editable. |
|
||||||
| `netzwerk.sponsoringForm.*` | The two visible form steps, validation, success state, and footer copy in `src/spa/components/forms/SponsoringForm.tsx`. |
|
| `netzwerk.sponsoringForm.*` | The two visible form steps, validation, success state, and footer copy in `src/spa/components/forms/SponsoringForm.tsx`. |
|
||||||
|
|
||||||
## Global ownership
|
## Global ownership
|
||||||
|
|||||||
@@ -201,10 +201,13 @@ export const netzwerkFields: Field[] = [
|
|||||||
text('detailLabel', 'Detail label', netzwerkContent.partners.detailLabel),
|
text('detailLabel', 'Detail label', netzwerkContent.partners.detailLabel),
|
||||||
{
|
{
|
||||||
name: 'tiers',
|
name: 'tiers',
|
||||||
label: 'Tiers',
|
label: 'Tier headings and supporting copy',
|
||||||
type: 'array',
|
type: 'array',
|
||||||
dbName: 'network_tiers',
|
dbName: 'network_tiers',
|
||||||
defaultValue: netzwerkContent.partners.tiers,
|
defaultValue: netzwerkContent.partners.tiers,
|
||||||
|
admin: {
|
||||||
|
description: 'One copy row per sponsor tier. Partner membership and order come from each Partner record.',
|
||||||
|
},
|
||||||
fields: [numberField('tier', 'Tier'), text('label', 'Label'), text('note', 'Note')],
|
fields: [numberField('tier', 'Tier'), text('label', 'Label'), text('note', 'Note')],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
|
||||||
|
|
||||||
|
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
|
||||||
|
|
||||||
|
const previousDescription =
|
||||||
|
'Partner in drei Kategorien: Hauptsponsoren, Medienpartner und weitere Sponsoren. Klicken für Details.'
|
||||||
|
const interimDescription =
|
||||||
|
'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Klicken für Details.'
|
||||||
|
const sponsorDescription =
|
||||||
|
'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Die gestaffelten Bänder machen die Gewichtung auf jeder Bildschirmgröße klar.'
|
||||||
|
|
||||||
|
const tierCopy = [
|
||||||
|
{ tier: 1, previousLabel: 'Hauptsponsoren', label: 'Große Darstellung' },
|
||||||
|
{ tier: 2, previousLabel: 'Medienpartner', label: 'Mittlere Darstellung' },
|
||||||
|
{ tier: 3, previousLabel: 'Weitere Sponsoren', label: 'Kompakte Darstellung' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
async function updateTierLabels(
|
||||||
|
db: MigrateUpArgs['db'],
|
||||||
|
tableName: 'network_tiers' | '_network_tiers_v',
|
||||||
|
direction: 'up' | 'down',
|
||||||
|
) {
|
||||||
|
for (const copy of tierCopy) {
|
||||||
|
const from = direction === 'up' ? copy.previousLabel : copy.label
|
||||||
|
const to = direction === 'up' ? copy.label : copy.previousLabel
|
||||||
|
await db.run(
|
||||||
|
sql.raw(
|
||||||
|
`UPDATE ${tableName} SET label = ${literal(to)} WHERE tier = ${copy.tier} AND label = ${literal(from)};`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.run(
|
||||||
|
sql.raw(
|
||||||
|
`UPDATE pages SET netzwerk_partners_description = ${literal(sponsorDescription)} WHERE netzwerk_partners_description IN (${literal(previousDescription)}, ${literal(interimDescription)});`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.run(
|
||||||
|
sql.raw(
|
||||||
|
`UPDATE _pages_v SET version_netzwerk_partners_description = ${literal(sponsorDescription)} WHERE version_netzwerk_partners_description IN (${literal(previousDescription)}, ${literal(interimDescription)});`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await updateTierLabels(db, 'network_tiers', 'up')
|
||||||
|
await updateTierLabels(db, '_network_tiers_v', 'up')
|
||||||
|
await db.run(sql.raw(`UPDATE network_tiers SET note = '' WHERE tier = 1 AND label = 'Große Darstellung' AND note = 'Premium';`))
|
||||||
|
await db.run(sql.raw(`UPDATE _network_tiers_v SET note = '' WHERE tier = 1 AND label = 'Große Darstellung' AND note = 'Premium';`))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.run(
|
||||||
|
sql.raw(
|
||||||
|
`UPDATE pages SET netzwerk_partners_description = ${literal(previousDescription)} WHERE netzwerk_partners_description = ${literal(sponsorDescription)};`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.run(
|
||||||
|
sql.raw(
|
||||||
|
`UPDATE _pages_v SET version_netzwerk_partners_description = ${literal(previousDescription)} WHERE version_netzwerk_partners_description = ${literal(sponsorDescription)};`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await updateTierLabels(db, 'network_tiers', 'down')
|
||||||
|
await updateTierLabels(db, '_network_tiers_v', 'down')
|
||||||
|
await db.run(sql.raw(`UPDATE network_tiers SET note = 'Premium' WHERE tier = 1 AND label = 'Hauptsponsoren' AND note = '';`))
|
||||||
|
await db.run(sql.raw(`UPDATE _network_tiers_v SET note = 'Premium' WHERE tier = 1 AND label = 'Hauptsponsoren' AND note = '';`))
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import * as migration_20260731_143000_remove_press_record_fallbacks from './2026
|
|||||||
import * as migration_20260731_160000_focused_payload_cleanup from './20260731_160000_focused_payload_cleanup';
|
import * as migration_20260731_160000_focused_payload_cleanup from './20260731_160000_focused_payload_cleanup';
|
||||||
import * as migration_20260731_170000_netzwerk_greeting_video from './20260731_170000_netzwerk_greeting_video';
|
import * as migration_20260731_170000_netzwerk_greeting_video from './20260731_170000_netzwerk_greeting_video';
|
||||||
import * as migration_20260731_180000_press_contact_image from './20260731_180000_press_contact_image';
|
import * as migration_20260731_180000_press_contact_image from './20260731_180000_press_contact_image';
|
||||||
|
import * as migration_20260731_190000_responsive_sponsor_hierarchy from './20260731_190000_responsive_sponsor_hierarchy';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
@@ -126,4 +127,9 @@ export const migrations = [
|
|||||||
down: migration_20260731_180000_press_contact_image.down,
|
down: migration_20260731_180000_press_contact_image.down,
|
||||||
name: '20260731_180000_press_contact_image',
|
name: '20260731_180000_press_contact_image',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260731_190000_responsive_sponsor_hierarchy.up,
|
||||||
|
down: migration_20260731_190000_responsive_sponsor_hierarchy.down,
|
||||||
|
name: '20260731_190000_responsive_sponsor_hierarchy',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1337,6 +1337,9 @@ export interface Page {
|
|||||||
heading?: string | null;
|
heading?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
detailLabel?: string | null;
|
detailLabel?: string | null;
|
||||||
|
/**
|
||||||
|
* One copy row per sponsor tier. Partner membership and order come from each Partner record.
|
||||||
|
*/
|
||||||
tiers?:
|
tiers?:
|
||||||
| {
|
| {
|
||||||
tier?: number | null;
|
tier?: number | null;
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ async function main() {
|
|||||||
const sponsoringImage = await mediaByFilename(payload, netzwerkContent.sponsoring.imageFilename)
|
const sponsoringImage = await mediaByFilename(payload, netzwerkContent.sponsoring.imageFilename)
|
||||||
const { imageFilename: _heroFilename, ...heroContent } = netzwerkContent.hero
|
const { imageFilename: _heroFilename, ...heroContent } = netzwerkContent.hero
|
||||||
const { imageFilename: _sponsoringFilename, ...sponsoringContent } = netzwerkContent.sponsoring
|
const { imageFilename: _sponsoringFilename, ...sponsoringContent } = netzwerkContent.sponsoring
|
||||||
const { items: _partnerItems, ...partnerSectionContent } = netzwerkContent.partners
|
const { items: _partnerItems, tiers: partnerTiers, ...partnerSectionContent } = netzwerkContent.partners
|
||||||
const existingGreetingVideo = page.netzwerk?.patronage?.greetingVideo || null
|
const existingGreetingVideo = page.netzwerk?.patronage?.greetingVideo || null
|
||||||
const [patronPeople, juryMembers] = await Promise.all([
|
const [patronPeople, juryMembers] = await Promise.all([
|
||||||
itemsWithImageUploads(payload, netzwerkContent.patronage.people),
|
itemsWithImageUploads(payload, netzwerkContent.patronage.people),
|
||||||
@@ -169,6 +169,7 @@ async function main() {
|
|||||||
},
|
},
|
||||||
partners: {
|
partners: {
|
||||||
...partnerSectionContent,
|
...partnerSectionContent,
|
||||||
|
tiers: partnerTiers.map(({ tier, label, note }) => ({ tier, label, note })),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export const netzwerkContent = {
|
|||||||
partners: {
|
partners: {
|
||||||
eyebrow: 'Netzwerkqualität',
|
eyebrow: 'Netzwerkqualität',
|
||||||
heading: 'PARTNER & SPONSOREN',
|
heading: 'PARTNER & SPONSOREN',
|
||||||
description: 'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Klicken für Details.',
|
description: 'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Die gestaffelten Bänder machen die Gewichtung auf jeder Bildschirmgröße klar.',
|
||||||
detailLabel: 'Details',
|
detailLabel: 'Details',
|
||||||
tiers: [
|
tiers: [
|
||||||
{ tier: 1, label: 'Große Darstellung', note: '' },
|
{ tier: 1, label: 'Große Darstellung', note: '' },
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ import { selectJuryDocs, sortJuryMembersQuoteFirst } from '@/spa/juryMembers';
|
|||||||
import { netzwerkContent } from '@/spa/netzwerkContent';
|
import { netzwerkContent } from '@/spa/netzwerkContent';
|
||||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||||
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo';
|
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo';
|
||||||
|
import {
|
||||||
|
groupSponsorsByTier,
|
||||||
|
sponsorCardBasis,
|
||||||
|
sponsorColumns,
|
||||||
|
sponsorTierNumbers,
|
||||||
|
sponsorViewport,
|
||||||
|
type SponsorTierNumber,
|
||||||
|
type SponsorViewport,
|
||||||
|
} from '@/spa/sponsorHierarchy';
|
||||||
|
|
||||||
const FF = '"IBM Plex Sans", sans-serif';
|
const FF = '"IBM Plex Sans", sans-serif';
|
||||||
const FB = '"Inter", sans-serif';
|
const FB = '"Inter", sans-serif';
|
||||||
@@ -51,17 +60,7 @@ type PartnerModalCopy = typeof netzwerkContent.partners.modal
|
|||||||
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
|
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||||
const fallbackArray = <T,>(value: unknown, fallback: T[]) => Array.isArray(value) && value.length ? value as T[] : fallback;
|
const fallbackArray = <T,>(value: unknown, fallback: T[]) => Array.isArray(value) && value.length ? value as T[] : fallback;
|
||||||
const managedImage = (upload: unknown, fallback: unknown) => mediaUrl(upload, fallbackText(fallback, ''))
|
const managedImage = (upload: unknown, fallback: unknown) => mediaUrl(upload, fallbackText(fallback, ''))
|
||||||
const deprecatedPartnerDescription = 'Partner in drei Kategorien: Hauptsponsoren, Medienpartner und weitere Sponsoren. Klicken für Details.'
|
|
||||||
const visiblePartnerDescription = (value: unknown) => {
|
|
||||||
const text = fallbackText(value, netzwerkContent.partners.description)
|
|
||||||
return text.trim() === deprecatedPartnerDescription ? netzwerkContent.partners.description : text
|
|
||||||
}
|
|
||||||
const juryImageFallback = '/images/preistraeger-placeholder.svg'
|
const juryImageFallback = '/images/preistraeger-placeholder.svg'
|
||||||
const sortedPartners = <T extends PartnerLogoData>(partners: T[]) =>
|
|
||||||
partners
|
|
||||||
.map((partner, index) => ({ partner, index }))
|
|
||||||
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
|
|
||||||
.map(({ partner }) => partner)
|
|
||||||
const normalizeJuryMember = (member: CmsRouteDoc): JuryMember => {
|
const normalizeJuryMember = (member: CmsRouteDoc): JuryMember => {
|
||||||
const organization = fallbackText(member.organization, '')
|
const organization = fallbackText(member.organization, '')
|
||||||
const rawRole = fallbackText(member.role, '')
|
const rawRole = fallbackText(member.role, '')
|
||||||
@@ -225,27 +224,46 @@ const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member
|
|||||||
|
|
||||||
// ── SponsorCell (tiered) ──────────────────────────────────────────────────────
|
// ── SponsorCell (tiered) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx: number; cols: number; detailLabel: string; onClick: () => void }> = ({ partner, variant, idx, cols, detailLabel, onClick }) => {
|
const SponsorCell: React.FC<{
|
||||||
|
partner: Partner
|
||||||
|
variant: 'lg' | 'md' | 'sm'
|
||||||
|
columns: number
|
||||||
|
detailLabel: string
|
||||||
|
gap: number
|
||||||
|
viewport: SponsorViewport
|
||||||
|
onClick: () => void
|
||||||
|
}> = ({ partner, variant, columns, detailLabel, gap, viewport, onClick }) => {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const isMobileCell = useIsMobile();
|
|
||||||
const lg = variant === 'lg';
|
const lg = variant === 'lg';
|
||||||
const sm = variant === 'sm';
|
const sm = variant === 'sm';
|
||||||
const lastCol = (idx % cols) === cols - 1;
|
const mobileCell = viewport === 'mobile' || viewport === 'narrow-mobile';
|
||||||
const logoHeight = isMobileCell ? (lg ? 76 : sm ? 54 : 62) : lg ? 92 : sm ? 62 : 76;
|
const logoHeight = mobileCell ? (lg ? 96 : sm ? 58 : 76) : lg ? 116 : sm ? 72 : 92;
|
||||||
|
const basis = sponsorCardBasis(columns, gap);
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${detailLabel}: ${partner.name}`}
|
||||||
|
data-testid={`sponsor-card-${partner.id}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
style={{
|
style={{
|
||||||
padding: isMobileCell ? '26px 20px' : lg ? '44px 38px 40px' : sm ? '30px 28px' : '36px 32px',
|
appearance: 'none',
|
||||||
borderRight: !lastCol && !isMobileCell ? '1px solid rgba(3,9,58,0.08)' : 'none',
|
boxSizing: 'border-box',
|
||||||
borderBottom: '1px solid rgba(3,9,58,0.08)',
|
width: basis,
|
||||||
|
flex: `0 0 ${basis}`,
|
||||||
|
minWidth: 0,
|
||||||
|
padding: mobileCell ? (sm ? '22px 16px' : '30px 24px') : lg ? '48px 42px 44px' : sm ? '30px 26px' : '38px 34px',
|
||||||
|
border: '1px solid rgba(3,9,58,0.1)',
|
||||||
|
borderTop: `${lg ? 4 : sm ? 2 : 3}px solid ${GOLD}`,
|
||||||
display: 'flex', flexDirection: 'column', gap: lg ? 18 : 16,
|
display: 'flex', flexDirection: 'column', gap: lg ? 18 : 16,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: hovered ? '#E8E5E4' : 'transparent',
|
background: hovered ? '#E8E5E4' : 'rgba(255,255,255,0.92)',
|
||||||
transition: 'background 0.15s',
|
transition: 'background 0.15s',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
textAlign: 'left',
|
||||||
|
color: 'inherit',
|
||||||
|
font: 'inherit',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ height: logoHeight, display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', minWidth: 0 }}>
|
<div style={{ height: logoHeight, display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', minWidth: 0 }}>
|
||||||
@@ -257,7 +275,7 @@ const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx
|
|||||||
<div style={{ fontFamily: FF, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: GOLD, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ fontFamily: FF, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: GOLD, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
{detailLabel} <ArrowRight size={10} />
|
{detailLabel} <ArrowRight size={10} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -452,6 +470,8 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
|
|||||||
|
|
||||||
const Netzwerk: React.FC = () => {
|
const Netzwerk: React.FC = () => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const isTabletOrMobile = useIsMobile(1200);
|
||||||
|
const isNarrowMobile = useIsMobile(360);
|
||||||
const [selectedPartner, setSelectedPartner] = useState<Partner | null>(null);
|
const [selectedPartner, setSelectedPartner] = useState<Partner | null>(null);
|
||||||
const [showGreetingVideo, setShowGreetingVideo] = useState(false);
|
const [showGreetingVideo, setShowGreetingVideo] = useState(false);
|
||||||
const greetingVideoRef = useRef<HTMLVideoElement>(null);
|
const greetingVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
@@ -474,13 +494,20 @@ const Netzwerk: React.FC = () => {
|
|||||||
.filter((member) => member.active !== false && member.name);
|
.filter((member) => member.active !== false && member.name);
|
||||||
const juryMembers = sortJuryMembersQuoteFirst(normalizedJuryMembers);
|
const juryMembers = sortJuryMembersQuoteFirst(normalizedJuryMembers);
|
||||||
const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
|
const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
|
||||||
const partners = sortedPartners(cmsPartners)
|
const partners = cmsPartners
|
||||||
.filter((partner) => partner.active !== false)
|
.filter((partner) => partner.active !== false)
|
||||||
.map((partner) => ({
|
.map((partner) => ({
|
||||||
...partner,
|
...partner,
|
||||||
id: partner.stableId || partner.id,
|
id: partner.stableId || partner.id,
|
||||||
}));
|
}));
|
||||||
const partnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers);
|
const configuredPartnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers);
|
||||||
|
const partnerTiers = sponsorTierNumbers.map((tier) => ({
|
||||||
|
...(netzwerkContent.partners.tiers.find((entry) => entry.tier === tier) as PartnerTier),
|
||||||
|
...(configuredPartnerTiers.find((entry) => Number(entry.tier) === tier) || {}),
|
||||||
|
tier,
|
||||||
|
}));
|
||||||
|
const sponsorsByTier = groupSponsorsByTier(partners);
|
||||||
|
const sponsorLayoutViewport = sponsorViewport({ isMobile, isNarrowMobile, isTabletOrMobile });
|
||||||
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
|
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
|
||||||
const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats);
|
const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats);
|
||||||
const sponsorBenefits = fallbackArray<BenefitItem>(sponsoring.benefits, netzwerkContent.sponsoring.benefits);
|
const sponsorBenefits = fallbackArray<BenefitItem>(sponsoring.benefits, netzwerkContent.sponsoring.benefits);
|
||||||
@@ -741,23 +768,61 @@ const Netzwerk: React.FC = () => {
|
|||||||
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}><Lines text={fallbackText(partnerSection.heading, netzwerkContent.partners.heading)} /></h2>
|
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}><Lines text={fallbackText(partnerSection.heading, netzwerkContent.partners.heading)} /></h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="type-body text-role-secondary" style={{ margin: 0 }}>
|
<p className="type-body text-role-secondary" style={{ margin: 0 }}>
|
||||||
{visiblePartnerDescription(partnerSection.description)}
|
{fallbackText(partnerSection.description, netzwerkContent.partners.description)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tiered grid – continuous, ranks marked by label bars */}
|
{/* Independent tier bands keep rank legible without mixing tiers to fill a row. */}
|
||||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 52px' : '24px 48px 80px' }}>
|
||||||
{partnerTiers.map(t => {
|
{partnerTiers.map(t => {
|
||||||
const tier = Number(t.tier) as 1 | 2 | 3;
|
const tier = Number(t.tier) as SponsorTierNumber;
|
||||||
const variant = tier === 1 ? 'lg' as const : tier === 2 ? 'md' as const : 'sm' as const;
|
const variant = tier === 1 ? 'lg' as const : tier === 2 ? 'md' as const : 'sm' as const;
|
||||||
const cols = isMobile ? (tier === 1 ? 1 : 2) : (tier === 3 ? 4 : 2);
|
const columns = sponsorColumns(tier, sponsorLayoutViewport);
|
||||||
const items = partners.filter(p => p.tier === tier);
|
const gap = isMobile ? 12 : tier === 3 ? 16 : 20;
|
||||||
|
const items = sponsorsByTier[tier];
|
||||||
|
const label = fallbackText(t.label, netzwerkContent.partners.tiers[tier - 1]?.label || '');
|
||||||
|
const note = fallbackText(t.note, '');
|
||||||
|
if (!items.length) return null;
|
||||||
return (
|
return (
|
||||||
<div key={t.tier} style={{ display: 'grid', gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
|
<section
|
||||||
{items.map((p, i) => (
|
key={tier}
|
||||||
<SponsorCell key={p.id} partner={p} variant={variant} idx={i} cols={cols} detailLabel={fallbackText(partnerSection.detailLabel, netzwerkContent.partners.detailLabel)} onClick={() => setSelectedPartner(p)} />
|
data-testid={`sponsor-tier-${tier}`}
|
||||||
))}
|
data-tier={tier}
|
||||||
</div>
|
data-columns={columns}
|
||||||
|
data-viewport={sponsorLayoutViewport}
|
||||||
|
style={{
|
||||||
|
maxWidth: 1240,
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: isMobile ? (tier === 1 ? '34px 0 14px' : '42px 0 14px') : (tier === 1 ? '50px 0 18px' : '62px 0 18px'),
|
||||||
|
borderBottom: tier === 3 ? 'none' : '1px solid rgba(3,9,58,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header style={{ maxWidth: tier === 1 ? 720 : 620, margin: '0 auto', textAlign: 'center', padding: isMobile ? '0 8px 24px' : '0 16px 32px' }}>
|
||||||
|
<h3 style={{ fontFamily: FF, fontSize: isMobile ? (tier === 1 ? 25 : 21) : (tier === 1 ? 34 : tier === 2 ? 28 : 23), fontWeight: 800, color: NAVY, textTransform: 'uppercase', letterSpacing: tier === 3 ? '0.08em' : '0.04em', lineHeight: 1.1, margin: 0 }}>
|
||||||
|
{label}
|
||||||
|
</h3>
|
||||||
|
{note ? (
|
||||||
|
<p style={{ fontFamily: FB, fontSize: isMobile ? 14 : 16, color: 'rgba(16,24,40,0.58)', lineHeight: 1.55, margin: '12px 0 0' }}>{note}</p>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
data-testid={`sponsor-tier-${tier}-cards`}
|
||||||
|
style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'stretch', gap }}
|
||||||
|
>
|
||||||
|
{items.map((partner) => (
|
||||||
|
<SponsorCell
|
||||||
|
key={partner.id}
|
||||||
|
partner={partner}
|
||||||
|
variant={variant}
|
||||||
|
columns={columns}
|
||||||
|
detailLabel={fallbackText(partnerSection.detailLabel, netzwerkContent.partners.detailLabel)}
|
||||||
|
gap={gap}
|
||||||
|
viewport={sponsorLayoutViewport}
|
||||||
|
onClick={() => setSelectedPartner(partner)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
59
src/spa/sponsorHierarchy.ts
Normal file
59
src/spa/sponsorHierarchy.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export const sponsorTierNumbers = [1, 2, 3] as const
|
||||||
|
|
||||||
|
export type SponsorTierNumber = (typeof sponsorTierNumbers)[number]
|
||||||
|
export type SponsorViewport = 'desktop' | 'tablet' | 'mobile' | 'narrow-mobile'
|
||||||
|
|
||||||
|
export type SponsorRecord = {
|
||||||
|
sortOrder?: number | null
|
||||||
|
tier: SponsorTierNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupSponsorsByTier<T extends SponsorRecord>(partners: T[]): Record<SponsorTierNumber, T[]> {
|
||||||
|
const grouped: Record<SponsorTierNumber, Array<{ index: number; partner: T }>> = {
|
||||||
|
1: [],
|
||||||
|
2: [],
|
||||||
|
3: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
partners.forEach((partner, index) => {
|
||||||
|
if (sponsorTierNumbers.includes(partner.tier)) grouped[partner.tier].push({ index, partner })
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
1: grouped[1]
|
||||||
|
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
|
||||||
|
.map(({ partner }) => partner),
|
||||||
|
2: grouped[2]
|
||||||
|
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
|
||||||
|
.map(({ partner }) => partner),
|
||||||
|
3: grouped[3]
|
||||||
|
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
|
||||||
|
.map(({ partner }) => partner),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sponsorColumns(tier: SponsorTierNumber, viewport: SponsorViewport): number {
|
||||||
|
if (viewport === 'desktop') return tier === 1 ? 2 : tier === 2 ? 3 : 4
|
||||||
|
if (viewport === 'tablet') return tier === 3 ? 3 : 2
|
||||||
|
if (viewport === 'narrow-mobile') return 1
|
||||||
|
return tier === 3 ? 2 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sponsorViewport({
|
||||||
|
isMobile,
|
||||||
|
isNarrowMobile,
|
||||||
|
isTabletOrMobile,
|
||||||
|
}: {
|
||||||
|
isMobile: boolean
|
||||||
|
isNarrowMobile: boolean
|
||||||
|
isTabletOrMobile: boolean
|
||||||
|
}): SponsorViewport {
|
||||||
|
if (isNarrowMobile) return 'narrow-mobile'
|
||||||
|
if (isMobile) return 'mobile'
|
||||||
|
if (isTabletOrMobile) return 'tablet'
|
||||||
|
return 'desktop'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sponsorCardBasis(columns: number, gap: number): string {
|
||||||
|
return `calc((100% - ${(columns - 1) * gap}px) / ${columns})`
|
||||||
|
}
|
||||||
183
tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx
Normal file
183
tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { cleanup, render, screen, within } from '@testing-library/react'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
|
||||||
|
import { netzwerkContent } from '@/spa/netzwerkContent'
|
||||||
|
import Netzwerk from '@/spa/pages/Netzwerk'
|
||||||
|
import {
|
||||||
|
groupSponsorsByTier,
|
||||||
|
sponsorCardBasis,
|
||||||
|
sponsorColumns,
|
||||||
|
sponsorViewport,
|
||||||
|
} from '@/spa/sponsorHierarchy'
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
usePathname: () => '/netzwerk',
|
||||||
|
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let viewportWidth = 1440
|
||||||
|
|
||||||
|
const partner = (name: string, tier: 1 | 2 | 3, sortOrder: number) => ({
|
||||||
|
id: name.toLowerCase().replace(/\s/g, '-'),
|
||||||
|
stableId: name.toLowerCase().replace(/\s/g, '-'),
|
||||||
|
name,
|
||||||
|
tier,
|
||||||
|
sortOrder,
|
||||||
|
active: true,
|
||||||
|
desc: `${name} description`,
|
||||||
|
fullDesc: `${name} details`,
|
||||||
|
industry: `${name} industry`,
|
||||||
|
location: `${name} location`,
|
||||||
|
website: `${name.toLowerCase()}.example`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const partners = [
|
||||||
|
partner('Tier One B', 1, 2),
|
||||||
|
partner('Tier Three H', 3, 8),
|
||||||
|
partner('Tier Two B', 2, 2),
|
||||||
|
partner('Tier Three A', 3, 1),
|
||||||
|
partner('Tier One A', 1, 1),
|
||||||
|
partner('Tier Three C', 3, 3),
|
||||||
|
partner('Tier Two A', 2, 1),
|
||||||
|
partner('Tier Three B', 3, 2),
|
||||||
|
partner('Tier Three D', 3, 4),
|
||||||
|
partner('Tier Three E', 3, 5),
|
||||||
|
partner('Tier Three F', 3, 6),
|
||||||
|
partner('Tier Three G', 3, 7),
|
||||||
|
]
|
||||||
|
|
||||||
|
const sponsorCopy = {
|
||||||
|
eyebrow: 'CMS sponsor eyebrow',
|
||||||
|
heading: 'CMS sponsor heading',
|
||||||
|
description: 'CMS sponsor supporting copy',
|
||||||
|
detailLabel: 'CMS sponsor action',
|
||||||
|
tiers: [
|
||||||
|
{ tier: 1, label: 'CMS feature tier', note: 'CMS feature tier note' },
|
||||||
|
{ tier: 2, label: 'CMS standard tier', note: 'CMS standard tier note' },
|
||||||
|
{ tier: 3, label: 'CMS compact tier', note: 'CMS compact tier note' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeData = (): CmsRouteData => ({
|
||||||
|
collection: 'pages',
|
||||||
|
doc: {
|
||||||
|
id: 'network-page',
|
||||||
|
slug: 'netzwerk',
|
||||||
|
spaPath: '/netzwerk',
|
||||||
|
netzwerk: { partners: sponsorCopy },
|
||||||
|
},
|
||||||
|
lists: {
|
||||||
|
partners,
|
||||||
|
'jury-mitglieder': [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderNetwork(width: number) {
|
||||||
|
viewportWidth = width
|
||||||
|
return render(
|
||||||
|
<CmsRouteProvider value={routeData()}>
|
||||||
|
<Netzwerk />
|
||||||
|
</CmsRouteProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('responsive sponsor hierarchy', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => {
|
||||||
|
const maximum = Number(query.match(/max-width:\s*(\d+)px/)?.[1] || Number.POSITIVE_INFINITY)
|
||||||
|
return {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
matches: viewportWidth <= maximum,
|
||||||
|
media: query,
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => cleanup())
|
||||||
|
|
||||||
|
it('groups by Partner tier and sorts each band by Partner sortOrder', () => {
|
||||||
|
const grouped = groupSponsorsByTier(partners)
|
||||||
|
|
||||||
|
expect(grouped[1].map(({ name }) => name)).toEqual(['Tier One A', 'Tier One B'])
|
||||||
|
expect(grouped[2].map(({ name }) => name)).toEqual(['Tier Two A', 'Tier Two B'])
|
||||||
|
expect(grouped[3].map(({ name }) => name)).toEqual([
|
||||||
|
'Tier Three A',
|
||||||
|
'Tier Three B',
|
||||||
|
'Tier Three C',
|
||||||
|
'Tier Three D',
|
||||||
|
'Tier Three E',
|
||||||
|
'Tier Three F',
|
||||||
|
'Tier Three G',
|
||||||
|
'Tier Three H',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defines the approved columns and conservative narrow-mobile fallback', () => {
|
||||||
|
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'desktop'))).toEqual([2, 3, 4])
|
||||||
|
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'tablet'))).toEqual([2, 2, 3])
|
||||||
|
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'mobile'))).toEqual([1, 1, 2])
|
||||||
|
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'narrow-mobile'))).toEqual([1, 1, 1])
|
||||||
|
expect(sponsorViewport({ isMobile: false, isNarrowMobile: false, isTabletOrMobile: true })).toBe('tablet')
|
||||||
|
expect(sponsorCardBasis(3, 16)).toBe('calc((100% - 32px) / 3)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders every visible sponsor-section label from Netzwerk CMS fields', () => {
|
||||||
|
renderNetwork(1440)
|
||||||
|
|
||||||
|
expect(screen.getByText(sponsorCopy.eyebrow)).toBeTruthy()
|
||||||
|
expect(screen.getByText(sponsorCopy.heading)).toBeTruthy()
|
||||||
|
expect(screen.getByText(sponsorCopy.description)).toBeTruthy()
|
||||||
|
sponsorCopy.tiers.forEach(({ label, note }) => {
|
||||||
|
expect(screen.getByRole('heading', { name: label })).toBeTruthy()
|
||||||
|
expect(screen.getByText(note)).toBeTruthy()
|
||||||
|
})
|
||||||
|
expect(screen.getAllByText(sponsorCopy.detailLabel)).toHaveLength(partners.length)
|
||||||
|
expect(screen.queryByText(netzwerkContent.partners.description)).toBeNull()
|
||||||
|
netzwerkContent.partners.tiers.forEach(({ label }) => expect(screen.queryByText(label)).toBeNull())
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ width: 1440, viewport: 'desktop', columns: [2, 3, 4] },
|
||||||
|
{ width: 1024, viewport: 'tablet', columns: [2, 2, 3] },
|
||||||
|
{ width: 390, viewport: 'mobile', columns: [1, 1, 2] },
|
||||||
|
{ width: 340, viewport: 'narrow-mobile', columns: [1, 1, 1] },
|
||||||
|
])('centers independent bands at $width px', ({ width, viewport, columns }) => {
|
||||||
|
renderNetwork(width)
|
||||||
|
|
||||||
|
columns.forEach((columnCount, index) => {
|
||||||
|
const tier = index + 1
|
||||||
|
const band = screen.getByTestId(`sponsor-tier-${tier}`)
|
||||||
|
const cards = screen.getByTestId(`sponsor-tier-${tier}-cards`)
|
||||||
|
expect(band.getAttribute('data-columns')).toBe(String(columnCount))
|
||||||
|
expect(band.getAttribute('data-viewport')).toBe(viewport)
|
||||||
|
expect(cards.style.display).toBe('flex')
|
||||||
|
expect(cards.style.flexWrap).toBe('wrap')
|
||||||
|
expect(cards.style.justifyContent).toBe('center')
|
||||||
|
expect(within(band).getAllByRole('button')).toHaveLength(tier === 3 ? 8 : 2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps sponsor defaults and legacy labels out of the rendered component source', () => {
|
||||||
|
const source = readFileSync(join(process.cwd(), 'src/spa/pages/Netzwerk.tsx'), 'utf8')
|
||||||
|
const visibleDefaults = [
|
||||||
|
netzwerkContent.partners.eyebrow,
|
||||||
|
netzwerkContent.partners.heading,
|
||||||
|
netzwerkContent.partners.description,
|
||||||
|
netzwerkContent.partners.detailLabel,
|
||||||
|
...netzwerkContent.partners.tiers.map(({ label }) => label),
|
||||||
|
'Partner in drei Kategorien: Hauptsponsoren, Medienpartner und weitere Sponsoren. Klicken für Details.',
|
||||||
|
]
|
||||||
|
|
||||||
|
visibleDefaults.forEach((copy) => {
|
||||||
|
expect(source).not.toContain(`'${copy}'`)
|
||||||
|
expect(source).not.toContain(`"${copy}"`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -106,7 +106,13 @@ describe('Payload field inventory', () => {
|
|||||||
'netzwerk.patronage.greetingVideoTitle',
|
'netzwerk.patronage.greetingVideoTitle',
|
||||||
'netzwerk.patronage.greetingVideoDescription',
|
'netzwerk.patronage.greetingVideoDescription',
|
||||||
'netzwerk.patronage.greetingVideoCloseLabel',
|
'netzwerk.patronage.greetingVideoCloseLabel',
|
||||||
|
'netzwerk.partners.eyebrow',
|
||||||
|
'netzwerk.partners.heading',
|
||||||
|
'netzwerk.partners.description',
|
||||||
|
'netzwerk.partners.detailLabel',
|
||||||
'netzwerk.partners.tiers',
|
'netzwerk.partners.tiers',
|
||||||
|
'netzwerk.partners.tiers.label',
|
||||||
|
'netzwerk.partners.tiers.note',
|
||||||
'netzwerk.partners.modal.websiteLabel',
|
'netzwerk.partners.modal.websiteLabel',
|
||||||
'netzwerk.sponsoringForm.steps',
|
'netzwerk.sponsoringForm.steps',
|
||||||
'netzwerk.sponsoringForm.requiredFieldError',
|
'netzwerk.sponsoringForm.requiredFieldError',
|
||||||
@@ -153,6 +159,15 @@ describe('Payload field inventory', () => {
|
|||||||
expect(field).not.toHaveProperty('defaultValue')
|
expect(field).not.toHaveProperty('defaultValue')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps sponsor tier copy separate from collection-owned tier assignment and ordering', () => {
|
||||||
|
expect(findField(netzwerkFields, 'netzwerk.partners.tiers')).toMatchObject({
|
||||||
|
type: 'array',
|
||||||
|
dbName: 'network_tiers',
|
||||||
|
})
|
||||||
|
expect(findField(netzwerkFields, 'netzwerk.partners.tiers.label')).toMatchObject({ type: 'text' })
|
||||||
|
expect(findField(netzwerkFields, 'netzwerk.partners.tiers.note')).toMatchObject({ type: 'text' })
|
||||||
|
})
|
||||||
|
|
||||||
it('limits the press contact upload to image media', () => {
|
it('limits the press contact upload to image media', () => {
|
||||||
const field = findField(pressIndexFields, 'pressIndex.downloads.contactImage')
|
const field = findField(pressIndexFields, 'pressIndex.downloads.contactImage')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user