diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index 3e70d5e..50dba44 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -640,340 +640,344 @@ export default function MobileBrandingPage() { - - - {t('events.branding.mode', 'Theme')} - - - setForm((prev) => ({ ...prev, mode: 'light' }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, mode: 'auto' }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, mode: 'dark' }))} - disabled={brandingDisabled} - /> - - + {form.useDefaultBranding ? null : ( + <> + + + {t('events.branding.mode', 'Theme')} + + + setForm((prev) => ({ ...prev, mode: 'light' }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, mode: 'auto' }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, mode: 'dark' }))} + disabled={brandingDisabled} + /> + + - - - {t('events.branding.colors', 'Colors')} - - setForm((prev) => ({ ...prev, primary: value }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, accent: value }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, background: value }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, surface: value }))} - disabled={brandingDisabled} - /> - - - - - {t('events.branding.fonts', 'Fonts')} - - setForm((prev) => ({ ...prev, headingFont: value }))} - onPicker={() => { - setFontField('heading'); - setShowFontsSheet(true); - }} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, bodyFont: value }))} - onPicker={() => { - setFontField('body'); - setShowFontsSheet(true); - }} - disabled={brandingDisabled} - /> - - {t('events.branding.fontSize', 'Font Size')} - - - setForm((prev) => ({ ...prev, fontSize: 's' }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, fontSize: 'm' }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, fontSize: 'l' }))} - disabled={brandingDisabled} - /> - - - - - - {t('events.branding.logo', 'Logo')} - - - {t('events.branding.logoHint', 'Upload a logo or use an emoji for the guest header.')} - - - setForm((prev) => ({ ...prev, logoMode: 'upload' }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, logoMode: 'emoticon' }))} - disabled={brandingDisabled} - /> - - - {form.logoMode === 'emoticon' ? ( - setForm((prev) => ({ ...prev, logoValue: value }))} - disabled={brandingDisabled} - /> - ) : ( - - {form.logoDataUrl ? ( - <> - {t('events.branding.logoAlt', - - document.getElementById('branding-logo-input')?.click()} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, logoDataUrl: '' }))} - > - - - - {t('events.branding.removeLogo', 'Remove')} - - - - - - ) : ( - <> - - document.getElementById('branding-logo-input')?.click()} - > - - - - {t('events.branding.uploadLogo', 'Upload logo (max. 1 MB)')} - - - - - )} - + + {t('events.branding.colors', 'Colors')} + + setForm((prev) => ({ ...prev, primary: value }))} disabled={brandingDisabled} - onChange={(event) => { - const file = event.target.files?.[0]; - if (!file) return; - if (file.size > 1024 * 1024) { - setError(t('events.branding.logoTooLarge', 'Logo must be under 1 MB.')); - return; - } - const reader = new FileReader(); - reader.onload = () => { - const nextLogo = - typeof reader.result === 'string' - ? reader.result - : typeof reader.result === 'object' && reader.result !== null - ? String(reader.result) - : ''; - setForm((prev) => ({ ...prev, logoDataUrl: nextLogo })); - setError(null); - }; - reader.readAsDataURL(file); - }} /> - - )} + setForm((prev) => ({ ...prev, accent: value }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, background: value }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, surface: value }))} + disabled={brandingDisabled} + /> + - - {t('events.branding.logoPosition', 'Position')} - - - setForm((prev) => ({ ...prev, logoPosition: 'left' }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, logoPosition: 'center' }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, logoPosition: 'right' }))} - disabled={brandingDisabled} - /> - - - {t('events.branding.logoSize', 'Size')} - - - setForm((prev) => ({ ...prev, logoSize: 's' }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, logoSize: 'm' }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, logoSize: 'l' }))} - disabled={brandingDisabled} - /> - - + + + {t('events.branding.fonts', 'Fonts')} + + setForm((prev) => ({ ...prev, headingFont: value }))} + onPicker={() => { + setFontField('heading'); + setShowFontsSheet(true); + }} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, bodyFont: value }))} + onPicker={() => { + setFontField('body'); + setShowFontsSheet(true); + }} + disabled={brandingDisabled} + /> + + {t('events.branding.fontSize', 'Font Size')} + + + setForm((prev) => ({ ...prev, fontSize: 's' }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, fontSize: 'm' }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, fontSize: 'l' }))} + disabled={brandingDisabled} + /> + + - - - {t('events.branding.buttons', 'Buttons & Links')} - - - {t('events.branding.buttonsHint', 'Style, radius, and link color for CTA buttons.')} - - - setForm((prev) => ({ ...prev, buttonStyle: 'filled' }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, buttonStyle: 'outline' }))} - disabled={brandingDisabled} - /> - - setForm((prev) => ({ ...prev, buttonRadius: value }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, buttonPrimary: value }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, buttonSecondary: value }))} - disabled={brandingDisabled} - /> - setForm((prev) => ({ ...prev, linkColor: value }))} - disabled={brandingDisabled} - /> - + + + {t('events.branding.logo', 'Logo')} + + + {t('events.branding.logoHint', 'Upload a logo or use an emoji for the guest header.')} + + + setForm((prev) => ({ ...prev, logoMode: 'upload' }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, logoMode: 'emoticon' }))} + disabled={brandingDisabled} + /> + + + {form.logoMode === 'emoticon' ? ( + setForm((prev) => ({ ...prev, logoValue: value }))} + disabled={brandingDisabled} + /> + ) : ( + + {form.logoDataUrl ? ( + <> + {t('events.branding.logoAlt', + + document.getElementById('branding-logo-input')?.click()} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, logoDataUrl: '' }))} + > + + + + {t('events.branding.removeLogo', 'Remove')} + + + + + + ) : ( + <> + + document.getElementById('branding-logo-input')?.click()} + > + + + + {t('events.branding.uploadLogo', 'Upload logo (max. 1 MB)')} + + + + + )} + { + const file = event.target.files?.[0]; + if (!file) return; + if (file.size > 1024 * 1024) { + setError(t('events.branding.logoTooLarge', 'Logo must be under 1 MB.')); + return; + } + const reader = new FileReader(); + reader.onload = () => { + const nextLogo = + typeof reader.result === 'string' + ? reader.result + : typeof reader.result === 'object' && reader.result !== null + ? String(reader.result) + : ''; + setForm((prev) => ({ ...prev, logoDataUrl: nextLogo })); + setError(null); + }; + reader.readAsDataURL(file); + }} + /> + + )} + + + {t('events.branding.logoPosition', 'Position')} + + + setForm((prev) => ({ ...prev, logoPosition: 'left' }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, logoPosition: 'center' }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, logoPosition: 'right' }))} + disabled={brandingDisabled} + /> + + + {t('events.branding.logoSize', 'Size')} + + + setForm((prev) => ({ ...prev, logoSize: 's' }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, logoSize: 'm' }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, logoSize: 'l' }))} + disabled={brandingDisabled} + /> + + + + + + {t('events.branding.buttons', 'Buttons & Links')} + + + {t('events.branding.buttonsHint', 'Style, radius, and link color for CTA buttons.')} + + + setForm((prev) => ({ ...prev, buttonStyle: 'filled' }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, buttonStyle: 'outline' }))} + disabled={brandingDisabled} + /> + + setForm((prev) => ({ ...prev, buttonRadius: value }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, buttonPrimary: value }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, buttonSecondary: value }))} + disabled={brandingDisabled} + /> + setForm((prev) => ({ ...prev, linkColor: value }))} + disabled={brandingDisabled} + /> + + + )} ) : ( renderWatermarkTab() diff --git a/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx b/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx new file mode 100644 index 0000000..06fcbfd --- /dev/null +++ b/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; + +const navigateMock = vi.fn(); +const backMock = vi.fn(); + +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigateMock, + useParams: () => ({ slug: 'demo-event' }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + }), + initReactI18next: { + type: '3rdParty', + init: () => undefined, + }, +})); + +vi.mock('react-hot-toast', () => ({ + default: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock('../components/MobileShell', () => ({ + MobileShell: ({ children }: { children: React.ReactNode }) =>
{children}
, + HeaderActionButton: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('../components/Primitives', () => ({ + MobileCard: ({ children }: { children: React.ReactNode }) =>
{children}
, + CTAButton: ({ label }: { label: string }) => , + SkeletonCard: () =>
Loading...
, +})); + +vi.mock('../components/Sheet', () => ({ + MobileSheet: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('../hooks/useBackNavigation', () => ({ + useBackNavigation: () => backMock, +})); + +vi.mock('../theme', () => ({ + ADMIN_COLORS: { + primary: '#ff5a5f', + accent: '#6d28d9', + }, + ADMIN_GRADIENTS: { softCard: 'linear-gradient(180deg, #fff, #f3f4f6)' }, + useAdminTheme: () => ({ + textStrong: '#0f172a', + muted: '#64748b', + subtle: '#94a3b8', + border: '#e2e8f0', + primary: '#ff5a5f', + accentSoft: '#f5f3ff', + danger: '#dc2626', + surfaceMuted: '#f8fafc', + surface: '#ffffff', + backdrop: '#0f172a', + dangerBg: '#fee2e2', + dangerText: '#b91c1c', + }), +})); + +vi.mock('@tamagui/stacks', () => ({ + YStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + XStack: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/text', () => ({ + SizableText: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('@tamagui/react-native-web-lite', () => ({ + Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => ( + + ), +})); + +vi.mock('tamagui', () => ({ + Slider: Object.assign( + ({ children }: { children: React.ReactNode }) =>
{children}
, + { + Track: ({ children }: { children: React.ReactNode }) =>
{children}
, + TrackActive: ({ children }: { children?: React.ReactNode }) =>
{children}
, + Thumb: () =>
, + } + ), +})); + +const getEventMock = vi.fn(); +const updateEventMock = vi.fn(); +const getTenantFontsMock = vi.fn(); +const getTenantSettingsMock = vi.fn(); +const trackOnboardingMock = vi.fn(); + +vi.mock('../../api', () => ({ + TenantEvent: {}, + TenantFont: {}, + WatermarkSettings: {}, + getEvent: (...args: unknown[]) => getEventMock(...args), + updateEvent: (...args: unknown[]) => updateEventMock(...args), + getTenantFonts: (...args: unknown[]) => getTenantFontsMock(...args), + getTenantSettings: (...args: unknown[]) => getTenantSettingsMock(...args), + trackOnboarding: (...args: unknown[]) => trackOnboardingMock(...args), +})); + +import MobileBrandingPage from '../BrandingPage'; + +const baseEvent = { + id: 9, + name: 'Demo Event', + slug: 'demo-event', + event_date: null, + event_type_id: 1, + event_type: null, + status: 'draft' as const, + settings: {}, +}; + +describe('MobileBrandingPage', () => { + beforeEach(() => { + getTenantFontsMock.mockResolvedValue([]); + getTenantSettingsMock.mockResolvedValue({ settings: {} }); + }); + + it('hides custom branding controls when default branding is active', async () => { + getEventMock.mockResolvedValueOnce({ + ...baseEvent, + settings: { branding: { use_default_branding: true } }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Branding Source')).toBeInTheDocument(); + }); + + expect(screen.queryByText('Theme')).toBeNull(); + expect(screen.queryByText('Colors')).toBeNull(); + }); + + it('shows custom branding controls when event branding is active', async () => { + getEventMock.mockResolvedValueOnce({ + ...baseEvent, + settings: { branding: { use_default_branding: false } }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Branding Source')).toBeInTheDocument(); + }); + + expect(screen.getByText('Theme')).toBeInTheDocument(); + expect(screen.getByText('Colors')).toBeInTheDocument(); + }); +});