fix(branding): route the gallery footer through <PoweredBy /> (#1008)

Closes #1003.

#999 centralised the attribution so branding_hide_powered_by is honoured
everywhere, but GalleryLayout kept its own inline guard. The gallery footer
therefore still flashed — it kept `!brandingSettings?.hide_powered_by`, where
undefined is falsy, so a white-labelled instance briefly showed the attribution
on first paint, on the surface a white-label customer is most likely to see.
And there were two implementations of one rule, which is the bug class #999
existed to close.

The footer appends the attribution to its copyright line inside an existing
<p>, so a straight swap would nest a <p> in a <p>. Added an inline variant
rendering a <span> that carries the leading ' | ' itself: the separator belongs
to the component, since a caller placing its own would have to repeat the
visibility guard to avoid leaving a dangling separator when the attribution is
hidden.

No extra request — GalleryView already uses usePublicSettings(), the same hook
and react-query key, so the cache is shared. The footer also picks up
common.poweredBy, so it is translated rather than hardcoded English.

Removes the now-unread hide_powered_by from GalleryLayout's prop type and the
mapping feeding it in GalleryView.

Four cases cover the variant — span not paragraph, separator present, separator
hidden with the attribution when white-labelled, hidden while loading. Each was
checked against the pre-fix shape: rendering a <p> or moving the separator out
breaks one.
This commit is contained in:
Paul Nothaft
2026-08-10 08:27:28 +02:00
committed by GitHub
parent 4ec93107a9
commit 1bf19a7caf
4 changed files with 58 additions and 8 deletions
+22 -2
View File
@@ -5,11 +5,21 @@ import { usePublicSettings } from '../../hooks/usePublicSettings';
interface PoweredByProps {
className?: string;
style?: React.CSSProperties;
/**
* Render inside an existing paragraph — as ` | Powered by PicPeak` in a
* `<span>` — rather than as its own `<p>`. The gallery footer appends the
* attribution to its copyright line, and a `<p>` nested in a `<p>` is invalid.
*
* The separator belongs to the component on purpose: a caller placing its own
* ` | ` would have to repeat the visibility guard to avoid leaving a dangling
* separator behind, and repeating that guard is what #999 was fixing.
*/
inline?: boolean;
}
// "Powered by PicPeak" footer/login attribution. Reads the setting itself (like
// DynamicFavicon) so callers don't repeat the guard; hidden for white-label.
export const PoweredBy: React.FC<PoweredByProps> = ({ className, style }) => {
export const PoweredBy: React.FC<PoweredByProps> = ({ className, style, inline = false }) => {
const { t } = useTranslation();
const { data: settings } = usePublicSettings();
@@ -17,9 +27,19 @@ export const PoweredBy: React.FC<PoweredByProps> = ({ className, style }) => {
// instance flashes the attribution before branding_hide_powered_by resolves.
if (!settings || settings.branding_hide_powered_by) return null;
const label = (
<>
{t('common.poweredBy')} <span className="font-semibold">PicPeak</span>
</>
);
if (inline) {
return <span className={className} style={style}> | {label}</span>;
}
return (
<p className={className} style={style}>
{t('common.poweredBy')} <span className="font-semibold">PicPeak</span>
{label}
</p>
);
};
@@ -60,4 +60,38 @@ describe('PoweredBy', () => {
expect(paragraph).toHaveClass('text-xs', 'mt-2');
expect(paragraph).toHaveStyle({ opacity: '0.5' });
});
// The gallery footer appends the attribution to its copyright line, inside an
// existing <p>. A nested <p> is invalid HTML, so that call site needs a span
// and the leading separator (#1003).
describe('inline variant', () => {
it('renders a span, not a paragraph, so it can live inside one', () => {
setSettings({});
const { container } = render(<PoweredBy inline />);
expect(screen.getByText('PicPeak').closest('p')).toBeNull();
expect(container.querySelector('span')).not.toBeNull();
});
it('carries its own separator', () => {
setSettings({});
const { container } = render(<PoweredBy inline />);
expect(container.textContent).toContain('| Powered by');
});
it('hides the separator along with the attribution when white-labelled', () => {
// The separator has to be inside the component: a caller rendering its
// own " | " would need to repeat the visibility guard, and would leave a
// dangling separator the moment it drifted.
setSettings({ branding_hide_powered_by: true });
const { container } = render(<PoweredBy inline />);
expect(container).toBeEmptyDOMElement();
expect(container.textContent).not.toContain('|');
});
it('hides while settings are still loading, like the block variant', () => {
setSettings(undefined);
const { container } = render(<PoweredBy inline />);
expect(container).toBeEmptyDOMElement();
});
});
});
@@ -5,7 +5,7 @@ import { Calendar, Clock, Download, LogOut, Facebook, Instagram, Twitter, Youtub
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, MarkdownContent } from '../common';
import { Button, MarkdownContent, PoweredBy } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
import { useTheme } from '../../contexts/ThemeContext';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
@@ -48,7 +48,6 @@ interface GalleryLayoutProps {
logo_display_header?: boolean;
logo_display_hero?: boolean;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
hide_powered_by?: boolean;
// Footer overhaul (#441 + #440). Empty strings hide each socials icon.
facebook_url?: string;
instagram_url?: string;
@@ -702,9 +701,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
)}
<p className="text-xs sm:text-sm text-muted-theme">
{brandingSettings?.footer_text || `© ${new Date().getFullYear()}${brandingSettings?.company_name ? ` ${brandingSettings.company_name}` : ''}. All rights reserved.`}
{!brandingSettings?.hide_powered_by && (
<> | Powered by <span className="font-semibold">PicPeak</span></>
)}
<PoweredBy inline />
</p>
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
<p className="text-xs text-muted-theme mt-2">
@@ -316,7 +316,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
logo_display_header: settingsData.branding_logo_display_header !== false,
logo_display_hero: settingsData.branding_logo_display_hero !== false,
logo_display_mode: settingsData.branding_logo_display_mode || 'logo_and_text',
hide_powered_by: settingsData.branding_hide_powered_by === true,
// Footer overhaul (#441 + #440). All five socials are optional;
// empty strings → that icon is hidden. promo_markdown is the
// global default; per-event override happens in GalleryLayout.