Update guest v2 branding and theming
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
import React from 'react';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
type AmbientBackgroundProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function AmbientBackground({ children }: AmbientBackgroundProps) {
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
|
||||
return (
|
||||
<YStack
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useEventData } from '../context/EventDataContext';
|
||||
import { buildEventPath } from '../lib/routes';
|
||||
import { useOptionalNotificationCenter } from '@/guest/context/NotificationCenterContext';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
type AppShellProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -31,8 +31,7 @@ export default function AppShell({ children }: AppShellProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const actionIconColor = isDark ? '#F8FAFF' : '#0F172A';
|
||||
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
|
||||
const showFab = !/\/photo\/\d+/.test(location.pathname);
|
||||
@@ -114,6 +113,7 @@ export default function AppShell({ children }: AppShellProps) {
|
||||
>
|
||||
<TopBar
|
||||
eventName={event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
|
||||
eventIcon={event?.type?.icon ?? null}
|
||||
onProfilePress={() => {
|
||||
setNotificationsOpen(false);
|
||||
setCompassOpen(false);
|
||||
|
||||
@@ -7,15 +7,14 @@ import { Home, Image, Share2 } from 'lucide-react';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { buildEventPath } from '../lib/routes';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
export default function BottomDock() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { token } = useEventData();
|
||||
const { t } = useTranslation();
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
|
||||
const dockItems = [
|
||||
{ key: 'home', label: t('navigation.home', 'Home'), path: '/', icon: Home },
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
export type CompassAction = {
|
||||
key: string;
|
||||
@@ -39,8 +39,7 @@ export default function CompassHub({
|
||||
title = 'Quick jump',
|
||||
}: CompassHubProps) {
|
||||
const close = () => onOpenChange(false);
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const [visible, setVisible] = React.useState(open);
|
||||
const [closing, setClosing] = React.useState(false);
|
||||
|
||||
|
||||
167
resources/js/guest-v2/components/EventLogo.tsx
Normal file
167
resources/js/guest-v2/components/EventLogo.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React from 'react';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Camera, Heart, PartyPopper, Users } from 'lucide-react';
|
||||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '@/guest/context/EventBrandingContext';
|
||||
import { getContrastingTextColor } from '@/guest/lib/color';
|
||||
import type { EventBranding } from '@/guest/types/event-branding';
|
||||
|
||||
type LogoSize = 's' | 'm' | 'l';
|
||||
|
||||
type EventLogoProps = {
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
logo?: EventBranding['logo'] | null;
|
||||
size?: LogoSize;
|
||||
};
|
||||
|
||||
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ size?: number; color?: string }>> = {
|
||||
heart: Heart,
|
||||
guests: Users,
|
||||
party: PartyPopper,
|
||||
camera: Camera,
|
||||
};
|
||||
|
||||
const LOGO_SIZE_MAP: Record<LogoSize, { container: number; image: number; emoji: number; icon: number; text: number }> = {
|
||||
s: { container: 32, image: 24, emoji: 16, icon: 14, text: 12 },
|
||||
m: { container: 40, image: 30, emoji: 18, icon: 18, text: 14 },
|
||||
l: { container: 48, image: 38, emoji: 22, icon: 22, text: 16 },
|
||||
};
|
||||
|
||||
function isLikelyEmoji(value: string): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const characters = Array.from(value.trim());
|
||||
if (characters.length === 0 || characters.length > 2) {
|
||||
return false;
|
||||
}
|
||||
return characters.some((char) => {
|
||||
const codePoint = char.codePointAt(0) ?? 0;
|
||||
return codePoint > 0x2600;
|
||||
});
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const words = name.split(' ').filter(Boolean);
|
||||
if (words.length >= 2) {
|
||||
return `${words[0][0]}${words[1][0]}`.toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export default function EventLogo({ name, icon, logo, size }: EventLogoProps) {
|
||||
const brandingContext = useOptionalEventBranding();
|
||||
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
||||
const resolvedLogo = logo ?? branding.logo;
|
||||
const logoMode = resolvedLogo?.mode ?? (branding.logoUrl ? 'upload' : 'emoticon');
|
||||
const logoValue = resolvedLogo?.value ?? branding.logoUrl ?? null;
|
||||
const logoSize = size ?? resolvedLogo?.size ?? 'm';
|
||||
const sizes = LOGO_SIZE_MAP[logoSize];
|
||||
const accentColor = branding.secondaryColor || DEFAULT_EVENT_BRANDING.secondaryColor;
|
||||
const textColor = getContrastingTextColor(accentColor, '#ffffff', '#0f172a');
|
||||
const [logoFailed, setLogoFailed] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLogoFailed(false);
|
||||
}, [logoValue]);
|
||||
|
||||
if (logoMode === 'upload' && logoValue && !logoFailed) {
|
||||
return (
|
||||
<YStack
|
||||
width={sizes.container}
|
||||
height={sizes.container}
|
||||
borderRadius={sizes.container}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="#ffffff"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(15, 23, 42, 0.08)"
|
||||
overflow="hidden"
|
||||
>
|
||||
<img
|
||||
src={logoValue}
|
||||
alt={name}
|
||||
style={{
|
||||
width: sizes.image,
|
||||
height: sizes.image,
|
||||
borderRadius: sizes.image,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
onError={() => setLogoFailed(true)}
|
||||
/>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (logoMode === 'emoticon' && logoValue && isLikelyEmoji(logoValue)) {
|
||||
return (
|
||||
<YStack
|
||||
width={sizes.container}
|
||||
height={sizes.container}
|
||||
borderRadius={sizes.container}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={accentColor}
|
||||
>
|
||||
<Text fontSize={sizes.emoji} color={textColor}>
|
||||
{logoValue}
|
||||
</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof icon === 'string') {
|
||||
const trimmed = icon.trim();
|
||||
if (trimmed) {
|
||||
const normalized = trimmed.toLowerCase();
|
||||
const IconComponent = EVENT_ICON_COMPONENTS[normalized];
|
||||
if (IconComponent) {
|
||||
return (
|
||||
<YStack
|
||||
width={sizes.container}
|
||||
height={sizes.container}
|
||||
borderRadius={sizes.container}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={accentColor}
|
||||
>
|
||||
<IconComponent size={sizes.icon} color={textColor} />
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLikelyEmoji(trimmed)) {
|
||||
return (
|
||||
<YStack
|
||||
width={sizes.container}
|
||||
height={sizes.container}
|
||||
borderRadius={sizes.container}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={accentColor}
|
||||
>
|
||||
<Text fontSize={sizes.emoji} color={textColor}>
|
||||
{trimmed}
|
||||
</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack
|
||||
width={sizes.container}
|
||||
height={sizes.container}
|
||||
borderRadius={sizes.container}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={accentColor}
|
||||
>
|
||||
<Text fontSize={sizes.text} color={textColor} fontWeight="$7">
|
||||
{getInitials(name)}
|
||||
</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
type FabAction = {
|
||||
key: string;
|
||||
@@ -25,8 +25,7 @@ const positions = [
|
||||
];
|
||||
|
||||
export default function FabActionRing({ open, onOpenChange, actions }: FabActionRingProps) {
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const borderColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const surfaceColor = isDark ? 'rgba(12, 16, 32, 0.92)' : 'rgba(255, 255, 255, 0.95)';
|
||||
const textColor = isDark ? '#F8FAFF' : '#0F172A';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { Sheet } from '@tamagui/sheet';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
export type FabAction = {
|
||||
key: string;
|
||||
@@ -21,8 +21,7 @@ type FabActionSheetProps = {
|
||||
};
|
||||
|
||||
export default function FabActionSheet({ open, onOpenChange, title, actions }: FabActionSheetProps) {
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { Flower } from 'lucide-react';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
type FloatingActionButtonProps = {
|
||||
onPress: () => void;
|
||||
@@ -10,8 +10,7 @@ type FloatingActionButtonProps = {
|
||||
|
||||
export default function FloatingActionButton({ onPress, onLongPress }: FloatingActionButtonProps) {
|
||||
const longPressTriggered = React.useRef(false);
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from '@tamagui/button';
|
||||
import { useConsent } from '@/contexts/consent';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { isUploadPath, shouldShowAnalyticsNudge } from '@/guest/lib/analyticsConsent';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
const PROMPT_STORAGE_KEY = 'fotospiel.guest.analyticsPrompt';
|
||||
const SNOOZE_MS = 60 * 60 * 1000;
|
||||
@@ -70,8 +70,7 @@ export default function GuestAnalyticsNudge({
|
||||
const lastPathRef = React.useRef(pathname);
|
||||
const lastActivityAtRef = React.useRef(Date.now());
|
||||
const visibleRef = React.useRef(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
|
||||
const isUpload = isUploadPath(pathname);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ScrollView } from '@tamagui/scroll-view';
|
||||
import { X } from 'lucide-react';
|
||||
import { useOptionalNotificationCenter } from '@/guest/context/NotificationCenterContext';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
type NotificationSheetProps = {
|
||||
open: boolean;
|
||||
@@ -16,8 +16,7 @@ type NotificationSheetProps = {
|
||||
export default function NotificationSheet({ open, onOpenChange }: NotificationSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const center = useOptionalNotificationCenter();
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
||||
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
||||
|
||||
@@ -177,8 +176,7 @@ export default function NotificationSheet({ open, onOpenChange }: NotificationSh
|
||||
}
|
||||
|
||||
function InfoBadge({ label, value }: { label: string; value: number }) {
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
|
||||
return (
|
||||
<YStack
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
type PhotoFrameTileProps = {
|
||||
height: number;
|
||||
@@ -17,8 +17,7 @@ export default function PhotoFrameTile({
|
||||
shimmer = false,
|
||||
shimmerDelayMs = 0,
|
||||
}: PhotoFrameTileProps) {
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
|
||||
return (
|
||||
<YStack
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useConsent } from '@/contexts/consent';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { buildEventPath } from '../lib/routes';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
const legalLinks = [
|
||||
{ slug: 'impressum', labelKey: 'settings.legal.section.impressum', fallback: 'Impressum' },
|
||||
@@ -37,8 +38,8 @@ export default function SettingsContent({ onNavigate, showHeader = true, onOpenL
|
||||
const { preferences, savePreferences } = useConsent();
|
||||
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const { token } = useEventData();
|
||||
const isDark = appearance === 'dark';
|
||||
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.65)' : 'rgba(255, 255, 255, 0.82)';
|
||||
const cardBorder = isDark ? 'rgba(148, 163, 184, 0.18)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
|
||||
@@ -307,8 +308,7 @@ function ClearCacheButton() {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [done, setDone] = React.useState(false);
|
||||
const { appearance } = useAppearance();
|
||||
const isDark = appearance === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
||||
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { ArrowLeft, X } from 'lucide-react';
|
||||
import SettingsContent from './SettingsContent';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||
import { LegalMarkdown } from '@/guest/components/legal-markdown';
|
||||
@@ -35,8 +35,7 @@ type SettingsSheetProps = {
|
||||
export default function SettingsSheet({ open, onOpenChange }: SettingsSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { locale } = useLocale();
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const [view, setView] = React.useState<ViewState>({ mode: 'home' });
|
||||
const isLegal = view.mode === 'legal';
|
||||
const legalDocument = useLegalDocument(isLegal ? view.slug : null, locale);
|
||||
@@ -177,8 +176,7 @@ export default function SettingsSheet({ open, onOpenChange }: SettingsSheetProps
|
||||
|
||||
function LegalView({ document, fallbackTitle }: { document: LegalDocumentState; fallbackTitle: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
|
||||
|
||||
if (document.phase === 'error') {
|
||||
|
||||
186
resources/js/guest-v2/components/ShareSheet.tsx
Normal file
186
resources/js/guest-v2/components/ShareSheet.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import { Sheet } from '@tamagui/sheet';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { Share2, MessageSquare, Copy, X } from 'lucide-react';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
type ShareSheetProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
photoId?: number | null;
|
||||
eventName?: string | null;
|
||||
url?: string | null;
|
||||
loading?: boolean;
|
||||
onShareNative: () => void;
|
||||
onShareWhatsApp: () => void;
|
||||
onShareMessages: () => void;
|
||||
onCopyLink: () => void;
|
||||
};
|
||||
|
||||
const WhatsAppIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden focusable="false" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function ShareSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
photoId,
|
||||
eventName,
|
||||
url,
|
||||
loading = false,
|
||||
onShareNative,
|
||||
onShareWhatsApp,
|
||||
onShareMessages,
|
||||
onCopyLink,
|
||||
}: ShareSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const mutedSurface = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
||||
const mutedBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
snapPoints={[60]}
|
||||
position={open ? 0 : -1}
|
||||
modal
|
||||
>
|
||||
<Sheet.Overlay {...({ backgroundColor: isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.3)' } as any)} />
|
||||
<Sheet.Frame padding="$4" backgroundColor="$surface" borderTopLeftRadius="$6" borderTopRightRadius="$6">
|
||||
<Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" />
|
||||
<YStack gap="$3">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$2" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
|
||||
{t('share.title', 'Shared photo')}
|
||||
</Text>
|
||||
{photoId ? (
|
||||
<Text fontSize="$5" fontWeight="$8">
|
||||
#{photoId}
|
||||
</Text>
|
||||
) : null}
|
||||
{eventName ? (
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{eventName}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<Button
|
||||
size="$3"
|
||||
circular
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={() => onOpenChange(false)}
|
||||
aria-label={t('common.actions.close', 'Close')}
|
||||
>
|
||||
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
</Button>
|
||||
</XStack>
|
||||
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<Button
|
||||
flex={1}
|
||||
minWidth="45%"
|
||||
borderRadius="$card"
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={onShareNative}
|
||||
disabled={loading}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Share2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<YStack>
|
||||
<Text fontSize="$2" fontWeight="$7">
|
||||
{t('share.button', 'Share')}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{t('share.title', 'Shared photo')}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</Button>
|
||||
<Button
|
||||
flex={1}
|
||||
minWidth="45%"
|
||||
borderRadius="$card"
|
||||
backgroundColor="#22C55E"
|
||||
onPress={onShareWhatsApp}
|
||||
disabled={loading}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<WhatsAppIcon width={18} height={18} />
|
||||
<YStack>
|
||||
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
|
||||
{t('share.whatsapp', 'WhatsApp')}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="rgba(255,255,255,0.8)">
|
||||
{loading ? '...' : ''}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</Button>
|
||||
<Button
|
||||
flex={1}
|
||||
minWidth="45%"
|
||||
borderRadius="$card"
|
||||
backgroundColor="#38BDF8"
|
||||
onPress={onShareMessages}
|
||||
disabled={loading}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<MessageSquare size={16} color="#FFFFFF" />
|
||||
<YStack>
|
||||
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
|
||||
{t('share.imessage', 'Messages')}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="rgba(255,255,255,0.8)">
|
||||
{loading ? '...' : ''}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</Button>
|
||||
<Button
|
||||
flex={1}
|
||||
minWidth="45%"
|
||||
borderRadius="$card"
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={onCopyLink}
|
||||
disabled={loading}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Copy size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<YStack>
|
||||
<Text fontSize="$2" fontWeight="$7">
|
||||
{t('share.copyLink', 'Copy link')}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{loading ? t('share.loading', 'Loading...') : ''}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</Button>
|
||||
</XStack>
|
||||
|
||||
{url ? (
|
||||
<Text fontSize="$1" color="$color" opacity={0.7} numberOfLines={1}>
|
||||
{url}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</Sheet.Frame>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import React from 'react';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import type { YStackProps } from '@tamagui/stacks';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
type SurfaceCardProps = YStackProps & {
|
||||
glow?: boolean;
|
||||
};
|
||||
|
||||
export default function SurfaceCard({ children, glow = false, ...props }: SurfaceCardProps) {
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const borderColor = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const boxShadow = isDark
|
||||
? glow
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Camera, CheckCircle2, Heart, RefreshCw, Sparkles, Timer as TimerIcon }
|
||||
import PhotoFrameTile from './PhotoFrameTile';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '@/guest/lib/emotionTheme';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
import { getBentoSurfaceTokens } from '../lib/bento';
|
||||
|
||||
type TaskHeroEmotion = EmotionIdentity & { emoji?: string | null };
|
||||
@@ -65,8 +65,7 @@ export default function TaskHeroCard({
|
||||
const heroCardRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const theme = getEmotionTheme(task?.emotion ?? null);
|
||||
const emotionIcon = getEmotionIcon(task?.emotion ?? null);
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const bentoSurface = getBentoSurfaceTokens(isDark);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
155
resources/js/guest-v2/components/ToastHost.tsx
Normal file
155
resources/js/guest-v2/components/ToastHost.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
type ToastAction = { label: string; onClick: () => void };
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
export type GuestToast = {
|
||||
id: number;
|
||||
text: string;
|
||||
type?: ToastType;
|
||||
action?: ToastAction;
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
type ToastPayload = Omit<GuestToast, 'id'>;
|
||||
|
||||
const DEFAULT_DURATION = 3000;
|
||||
|
||||
export default function ToastHost() {
|
||||
const [list, setList] = React.useState<GuestToast[]>([]);
|
||||
const timeouts = React.useRef<Map<number, number>>(new Map());
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
|
||||
const dismiss = React.useCallback((id: number) => {
|
||||
setList((arr) => arr.filter((t) => t.id !== id));
|
||||
const timeout = timeouts.current.get(id);
|
||||
if (timeout) {
|
||||
window.clearTimeout(timeout);
|
||||
timeouts.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const push = React.useCallback(
|
||||
(toast: ToastPayload) => {
|
||||
const id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
const durationMs = toast.durationMs ?? DEFAULT_DURATION;
|
||||
setList((arr) => [...arr, { id, ...toast, durationMs }]);
|
||||
if (durationMs > 0 && typeof window !== 'undefined') {
|
||||
const timeout = window.setTimeout(() => dismiss(id), durationMs);
|
||||
timeouts.current.set(id, timeout);
|
||||
}
|
||||
},
|
||||
[dismiss]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const onEvt = (e: Event) => {
|
||||
const detail = (e as CustomEvent<ToastPayload>).detail;
|
||||
if (detail?.text) {
|
||||
push(detail);
|
||||
}
|
||||
};
|
||||
window.addEventListener('guest-toast', onEvt as EventListener);
|
||||
return () => window.removeEventListener('guest-toast', onEvt as EventListener);
|
||||
}, [push]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
for (const timeout of timeouts.current.values()) {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
timeouts.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolveTone = (type?: ToastType) => {
|
||||
if (type === 'error') {
|
||||
return {
|
||||
border: isDark ? 'rgba(248, 113, 113, 0.5)' : 'rgba(248, 113, 113, 0.4)',
|
||||
background: isDark ? 'rgba(127, 29, 29, 0.7)' : 'rgba(254, 226, 226, 0.95)',
|
||||
text: isDark ? '#FEE2E2' : '#991B1B',
|
||||
};
|
||||
}
|
||||
if (type === 'info') {
|
||||
return {
|
||||
border: isDark ? 'rgba(59, 130, 246, 0.45)' : 'rgba(59, 130, 246, 0.35)',
|
||||
background: isDark ? 'rgba(30, 64, 175, 0.65)' : 'rgba(219, 234, 254, 0.95)',
|
||||
text: isDark ? '#DBEAFE' : '#1D4ED8',
|
||||
};
|
||||
}
|
||||
return {
|
||||
border: isDark ? 'rgba(34, 197, 94, 0.45)' : 'rgba(34, 197, 94, 0.35)',
|
||||
background: isDark ? 'rgba(22, 101, 52, 0.7)' : 'rgba(220, 252, 231, 0.95)',
|
||||
text: isDark ? '#DCFCE7' : '#166534',
|
||||
};
|
||||
};
|
||||
|
||||
if (!list.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack
|
||||
position="fixed"
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={24}
|
||||
zIndex={2000}
|
||||
alignItems="center"
|
||||
pointerEvents="none"
|
||||
paddingHorizontal="$3"
|
||||
>
|
||||
<YStack width="100%" maxWidth={360} gap="$2">
|
||||
{list.map((toast) => {
|
||||
const tone = resolveTone(toast.type);
|
||||
return (
|
||||
<XStack
|
||||
key={toast.id}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap="$3"
|
||||
padding="$3"
|
||||
borderRadius="$4"
|
||||
borderWidth={1}
|
||||
pointerEvents="auto"
|
||||
style={{
|
||||
backgroundColor: tone.background,
|
||||
borderColor: tone.border,
|
||||
boxShadow: isDark ? '0 10px 20px rgba(2, 6, 23, 0.45)' : '0 10px 18px rgba(15, 23, 42, 0.12)',
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$2" color={tone.text} flex={1}>
|
||||
{toast.text}
|
||||
</Text>
|
||||
{toast.action ? (
|
||||
<Button
|
||||
size="$2"
|
||||
borderRadius="$pill"
|
||||
backgroundColor="transparent"
|
||||
borderWidth={1}
|
||||
borderColor={tone.border}
|
||||
onPress={() => {
|
||||
try {
|
||||
toast.action?.onClick();
|
||||
} finally {
|
||||
dismiss(toast.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$1" fontWeight="$7" color={tone.text} textTransform="uppercase" letterSpacing={1.2}>
|
||||
{toast.action.label}
|
||||
</Text>
|
||||
</Button>
|
||||
) : null}
|
||||
</XStack>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import { XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { Bell, Settings } from 'lucide-react';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '@/guest/context/EventBrandingContext';
|
||||
import EventLogo from './EventLogo';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
type TopBarProps = {
|
||||
eventName: string;
|
||||
eventIcon?: string | null;
|
||||
onProfilePress?: () => void;
|
||||
onNotificationsPress?: () => void;
|
||||
notificationCount?: number;
|
||||
@@ -14,18 +17,28 @@ type TopBarProps = {
|
||||
|
||||
export default function TopBar({
|
||||
eventName,
|
||||
eventIcon,
|
||||
onProfilePress,
|
||||
onNotificationsPress,
|
||||
notificationCount = 0,
|
||||
}: TopBarProps) {
|
||||
const { resolved } = useAppearance();
|
||||
const isDark = resolved === 'dark';
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const brandingContext = useOptionalEventBranding();
|
||||
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
||||
const logoPosition = branding.logo?.position ?? 'left';
|
||||
const [animationKey, setAnimationKey] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
setAnimationKey((prev) => prev + 1);
|
||||
}, [eventName]);
|
||||
|
||||
const identityDirection = logoPosition === 'right'
|
||||
? 'row-reverse'
|
||||
: logoPosition === 'center'
|
||||
? 'column'
|
||||
: 'row';
|
||||
const identityAlign = logoPosition === 'center' ? 'center' : 'flex-start';
|
||||
|
||||
return (
|
||||
<XStack
|
||||
alignItems="center"
|
||||
@@ -38,19 +51,29 @@ export default function TopBar({
|
||||
WebkitBackdropFilter: 'saturate(120%) blur(8px)',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
<XStack
|
||||
key={animationKey}
|
||||
fontSize="$8"
|
||||
fontFamily="$display"
|
||||
fontWeight="$8"
|
||||
numberOfLines={1}
|
||||
className="guest-topbar-title"
|
||||
alignItems={identityAlign}
|
||||
gap="$2"
|
||||
flexDirection={identityDirection}
|
||||
flexShrink={1}
|
||||
minWidth={0}
|
||||
style={{ fontSize: 'clamp(20px, 4.6vw, 30px)', lineHeight: '1.1' }}
|
||||
>
|
||||
{eventName}
|
||||
</Text>
|
||||
<EventLogo name={eventName} icon={eventIcon} logo={branding.logo} size="s" />
|
||||
<Text
|
||||
fontSize="$8"
|
||||
fontFamily="$display"
|
||||
fontWeight="$8"
|
||||
numberOfLines={1}
|
||||
className="guest-topbar-title"
|
||||
textAlign={logoPosition === 'center' ? 'center' : 'left'}
|
||||
flexShrink={1}
|
||||
minWidth={0}
|
||||
style={{ fontSize: 'clamp(20px, 4.6vw, 30px)', lineHeight: '1.1' }}
|
||||
>
|
||||
{eventName}
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack gap="$2" alignItems="center">
|
||||
<Button
|
||||
size="$3"
|
||||
|
||||
Reference in New Issue
Block a user