fix(promo-banner): center by default + admin alignment selector (#482)

The gallery promotional banner (#440) read as visually offset from
the gallery footer because:

  - Footer used `container text-center px-4` (full container width,
    centered text).
  - Promo block used `container py-4 sm:py-6` with an inner
    `max-w-3xl mx-auto` wrapper holding left-aligned text — a
    narrower column with left-aligned content sitting in the
    middle of the page.

Two issues compounded: the column was narrower than the footer AND
its text alignment differed. Reported by Rekoo-PS in #482 with a
screenshot showing the misalignment, with a request for an admin
alignment option.

Fix:

- Drop the inner max-w-3xl wrapper. Promo content now spans the
  same .container width as the footer, eliminating the
  narrower-column visual.
- Default text alignment changed from left → center to match the
  footer.
- New `branding_promo_alignment` setting ('left' | 'center' | 'right',
  default 'center'). Surfaced as a dropdown next to the existing
  Position dropdown on the BrandingPage. Live preview block on the
  BrandingPage mirrors the gallery render so admins see what
  guests will see.
- Also replaced the no-op `prose-sm` prose-modifier with a real
  `prose prose-sm` outer class so the existing `prose-a:text-accent`
  modifier actually takes effect (it didn't before — modifiers
  without an outer .prose are silently ignored by Tailwind
  Typography).

Migration 103 seeds the new setting at 'center' so existing
installs that have a promo banner today see the corrected
alignment immediately on next deploy.

i18n: en + de hand-translated; nl/pt/ru/fr machine-translated and
flagged for native review per project convention.
This commit is contained in:
Paul Nothaft
2026-05-14 20:10:14 +02:00
parent 3869e5c0dc
commit a803491cf4
14 changed files with 183 additions and 20 deletions
@@ -47,6 +47,9 @@ interface GalleryLayoutProps {
youtube_url?: string;
promo_markdown?: string;
promo_position?: 'above_footer' | 'below_footer';
// Horizontal alignment for the promo content (#482). Defaults
// to 'center' so the banner aligns with the footer.
promo_alignment?: 'left' | 'center' | 'right';
};
showLogout?: boolean;
onLogout?: () => void;
@@ -226,12 +229,36 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
})().trim();
const promoPosition: 'above_footer' | 'below_footer' = brandingSettings?.promo_position === 'below_footer' ? 'below_footer' : 'above_footer';
// Promo alignment (#482). Defaults to 'center' to match the gallery
// footer's `text-center px-4` so the banner reads as part of the
// same composition as the footer beneath it. Admin can flip to
// 'left' or 'right' from Settings → Branding.
const promoAlignment: 'left' | 'center' | 'right' =
brandingSettings?.promo_alignment === 'left' ? 'left'
: brandingSettings?.promo_alignment === 'right' ? 'right'
: 'center';
const promoTextAlignClass =
promoAlignment === 'left' ? 'text-left'
: promoAlignment === 'right' ? 'text-right'
: 'text-center';
const promoSlot = promoMarkdown ? (
<div className="gallery-promo border-t border-surface bg-surface/50">
<div className="container py-4 sm:py-6">
<div className="max-w-3xl mx-auto text-sm text-theme">
<MarkdownContent source={promoMarkdown} className="prose-sm prose-a:text-accent" />
</div>
{/*
* Inner block uses .container (matches the footer's container
* width) + the alignment class. We deliberately drop the
* previous max-w-3xl wrapper — it created a narrower column
* that read as visually offset from the full-width footer
* (#482, reported by Rekoo-PS). The `prose` class is needed
* for the prose-a:text-accent modifier to actually take effect
* (modifiers without an outer .prose are no-ops in Tailwind
* Typography).
*/}
<div className={`container py-4 sm:py-6 px-4 ${promoTextAlignClass}`}>
<MarkdownContent
source={promoMarkdown}
className={`prose prose-sm max-w-none mx-auto text-sm text-theme prose-a:text-accent ${promoTextAlignClass}`}
/>
</div>
</div>
) : null;
@@ -253,6 +253,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
youtube_url: settingsData.branding_youtube_url || '',
promo_markdown: settingsData.branding_promo_markdown || '',
promo_position: settingsData.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer',
// Promo alignment (#482). Defaults to 'center' to match the
// gallery footer; see GalleryLayout.
promo_alignment: ['left', 'center', 'right'].includes(settingsData.branding_promo_alignment)
? settingsData.branding_promo_alignment
: 'center',
});
}
}, [settingsData]);
+6
View File
@@ -1792,6 +1792,12 @@
"sizeMedium": "Mittel (Standard)",
"sizeLarge": "Groß",
"sizeXLarge": "Sehr groß"
},
"promo": {
"alignment": "Ausrichtung",
"alignLeft": "Links",
"alignCenter": "Zentriert (Standard wie der Footer)",
"alignRight": "Rechts"
}
},
"admin": {
+6
View File
@@ -1462,6 +1462,12 @@
"sizeMedium": "Medium (default)",
"sizeLarge": "Large",
"sizeXLarge": "Extra large"
},
"promo": {
"alignment": "Alignment",
"alignLeft": "Left",
"alignCenter": "Center (default — matches footer)",
"alignRight": "Right"
}
},
"admin": {
+6
View File
@@ -1400,6 +1400,12 @@
"sizeMedium": "Moyenne (par défaut)",
"sizeLarge": "Grande",
"sizeXLarge": "Très grande"
},
"promo": {
"alignment": "Alignement",
"alignLeft": "À gauche",
"alignCenter": "Centré (par défaut — identique au pied de page)",
"alignRight": "À droite"
}
},
"admin": {
+6
View File
@@ -1462,6 +1462,12 @@
"sizeMedium": "Middel (standaard)",
"sizeLarge": "Groot",
"sizeXLarge": "Extra groot"
},
"promo": {
"alignment": "Uitlijning",
"alignLeft": "Links",
"alignCenter": "Gecentreerd (standaard — komt overeen met footer)",
"alignRight": "Rechts"
}
},
"admin": {
+6
View File
@@ -1479,6 +1479,12 @@
"sizeMedium": "Médio (padrão)",
"sizeLarge": "Grande",
"sizeXLarge": "Extra grande"
},
"promo": {
"alignment": "Alinhamento",
"alignLeft": "Esquerda",
"alignCenter": "Centro (padrão — igual ao rodapé)",
"alignRight": "Direita"
}
},
"admin": {
+6
View File
@@ -1496,6 +1496,12 @@
"sizeMedium": "Средний (по умолчанию)",
"sizeLarge": "Большой",
"sizeXLarge": "Очень большой"
},
"promo": {
"alignment": "Выравнивание",
"alignLeft": "По левому краю",
"alignCenter": "По центру (по умолчанию — как в подвале)",
"alignRight": "По правому краю"
}
},
"admin": {
+39 -13
View File
@@ -43,6 +43,7 @@ export const BrandingPage: React.FC = () => {
youtube_url: '',
promo_markdown: '',
promo_position: 'above_footer',
promo_alignment: 'center',
});
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
@@ -420,18 +421,36 @@ export const BrandingPage: React.FC = () => {
{t('branding.promo.help', 'Markdown shown above or below the gallery footer (e.g. seasonal offer, print discount). Per-event overrides take priority.')}
</p>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.promo.position', 'Position')}
</label>
<select
value={brandingSettings.promo_position || 'above_footer'}
onChange={(e) => handleBrandingChange('promo_position', e.target.value as 'above_footer' | 'below_footer')}
className="w-full sm:w-64 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="above_footer">{t('branding.promo.aboveFooter', 'Above footer')}</option>
<option value="below_footer">{t('branding.promo.belowFooter', 'Below footer')}</option>
</select>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.promo.position', 'Position')}
</label>
<select
value={brandingSettings.promo_position || 'above_footer'}
onChange={(e) => handleBrandingChange('promo_position', e.target.value as 'above_footer' | 'below_footer')}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="above_footer">{t('branding.promo.aboveFooter', 'Above footer')}</option>
<option value="below_footer">{t('branding.promo.belowFooter', 'Below footer')}</option>
</select>
</div>
{/* Horizontal alignment (#482). Defaults to center
so the banner aligns with the gallery footer. */}
<div className="flex-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.promo.alignment', 'Alignment')}
</label>
<select
value={brandingSettings.promo_alignment || 'center'}
onChange={(e) => handleBrandingChange('promo_alignment', e.target.value as 'left' | 'center' | 'right')}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="left">{t('branding.promo.alignLeft', 'Left')}</option>
<option value="center">{t('branding.promo.alignCenter', 'Center (default — matches footer)')}</option>
<option value="right">{t('branding.promo.alignRight', 'Right')}</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
@@ -453,9 +472,16 @@ export const BrandingPage: React.FC = () => {
<div className="text-xs uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
{t('branding.promo.preview', 'Preview')}
</div>
{/* Preview mirrors the live gallery render — same
alignment class so the admin sees what guests
will see (#482). */}
<MarkdownContent
source={brandingSettings.promo_markdown}
className="text-sm text-neutral-800 dark:text-neutral-200 prose-sm prose-a:text-primary-600 dark:prose-a:text-primary-400"
className={`text-sm text-neutral-800 dark:text-neutral-200 prose prose-sm prose-a:text-primary-600 dark:prose-a:text-primary-400 ${
brandingSettings.promo_alignment === 'left' ? 'text-left'
: brandingSettings.promo_alignment === 'right' ? 'text-right'
: 'text-center'
}`}
/>
</div>
)}
@@ -39,6 +39,9 @@ export interface PublicSettings {
branding_youtube_url?: string;
branding_promo_markdown?: string;
branding_promo_position?: 'above_footer' | 'below_footer';
// Per-install promo banner alignment (#482). Defaults to 'center'
// so the banner aligns with the gallery footer's centering.
branding_promo_alignment?: 'left' | 'center' | 'right';
theme_config: any;
default_language: string;
enable_analytics: boolean;
+7 -1
View File
@@ -42,6 +42,9 @@ export interface BrandingSettings {
youtube_url?: string;
promo_markdown?: string;
promo_position?: 'above_footer' | 'below_footer';
// Per-install promo banner alignment (#482). Defaults to center
// so the banner aligns with the gallery footer.
promo_alignment?: 'left' | 'center' | 'right';
}
export interface ThemeSettings {
@@ -336,7 +339,10 @@ export const settingsService = {
twitter_url: rawSettings.branding_twitter_url || '',
youtube_url: rawSettings.branding_youtube_url || '',
promo_markdown: rawSettings.branding_promo_markdown || '',
promo_position: rawSettings.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer'
promo_position: rawSettings.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer',
promo_alignment: ['left', 'center', 'right'].includes(rawSettings.branding_promo_alignment)
? rawSettings.branding_promo_alignment
: 'center'
};
},