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 {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user