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:
@@ -5,11 +5,21 @@ import { usePublicSettings } from '../../hooks/usePublicSettings';
|
|||||||
interface PoweredByProps {
|
interface PoweredByProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
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
|
// "Powered by PicPeak" footer/login attribution. Reads the setting itself (like
|
||||||
// DynamicFavicon) so callers don't repeat the guard; hidden for white-label.
|
// 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 { t } = useTranslation();
|
||||||
const { data: settings } = usePublicSettings();
|
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.
|
// instance flashes the attribution before branding_hide_powered_by resolves.
|
||||||
if (!settings || settings.branding_hide_powered_by) return null;
|
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 (
|
return (
|
||||||
<p className={className} style={style}>
|
<p className={className} style={style}>
|
||||||
{t('common.poweredBy')} <span className="font-semibold">PicPeak</span>
|
{label}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,4 +60,38 @@ describe('PoweredBy', () => {
|
|||||||
expect(paragraph).toHaveClass('text-xs', 'mt-2');
|
expect(paragraph).toHaveClass('text-xs', 'mt-2');
|
||||||
expect(paragraph).toHaveStyle({ opacity: '0.5' });
|
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 { parseISO } from 'date-fns';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
import { Button, MarkdownContent } from '../common';
|
import { Button, MarkdownContent, PoweredBy } from '../common';
|
||||||
import { DynamicFavicon } from '../common/DynamicFavicon';
|
import { DynamicFavicon } from '../common/DynamicFavicon';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||||
@@ -48,7 +48,6 @@ interface GalleryLayoutProps {
|
|||||||
logo_display_header?: boolean;
|
logo_display_header?: boolean;
|
||||||
logo_display_hero?: boolean;
|
logo_display_hero?: boolean;
|
||||||
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
|
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
|
||||||
hide_powered_by?: boolean;
|
|
||||||
// Footer overhaul (#441 + #440). Empty strings hide each socials icon.
|
// Footer overhaul (#441 + #440). Empty strings hide each socials icon.
|
||||||
facebook_url?: string;
|
facebook_url?: string;
|
||||||
instagram_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">
|
<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?.footer_text || `© ${new Date().getFullYear()}${brandingSettings?.company_name ? ` ${brandingSettings.company_name}` : ''}. All rights reserved.`}
|
||||||
{!brandingSettings?.hide_powered_by && (
|
<PoweredBy inline />
|
||||||
<> | Powered by <span className="font-semibold">PicPeak</span></>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
|
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
|
||||||
<p className="text-xs text-muted-theme mt-2">
|
<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_header: settingsData.branding_logo_display_header !== false,
|
||||||
logo_display_hero: settingsData.branding_logo_display_hero !== false,
|
logo_display_hero: settingsData.branding_logo_display_hero !== false,
|
||||||
logo_display_mode: settingsData.branding_logo_display_mode || 'logo_and_text',
|
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;
|
// Footer overhaul (#441 + #440). All five socials are optional;
|
||||||
// empty strings → that icon is hidden. promo_markdown is the
|
// empty strings → that icon is hidden. promo_markdown is the
|
||||||
// global default; per-event override happens in GalleryLayout.
|
// global default; per-event override happens in GalleryLayout.
|
||||||
|
|||||||
Reference in New Issue
Block a user