Update guest v2 branding and theming
This commit is contained in:
@@ -41,6 +41,7 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class EventPublicController extends BaseController
|
class EventPublicController extends BaseController
|
||||||
@@ -1734,6 +1735,7 @@ class EventPublicController extends BaseController
|
|||||||
'name' => $event->name,
|
'name' => $event->name,
|
||||||
'city' => $event->city,
|
'city' => $event->city,
|
||||||
] : null,
|
] : null,
|
||||||
|
'branding' => $event ? $this->resolveBrandingPayload($event) : null,
|
||||||
])->header('Cache-Control', 'no-store');
|
])->header('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1980,6 +1982,47 @@ class EventPublicController extends BaseController
|
|||||||
])->header('Cache-Control', 'no-store');
|
])->header('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function qr(Request $request, string $token): JsonResponse
|
||||||
|
{
|
||||||
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
|
|
||||||
|
if ($result instanceof JsonResponse) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[, $joinToken] = $result;
|
||||||
|
|
||||||
|
$joinTokenValue = $joinToken->token ?? $token;
|
||||||
|
$qrCodeUrl = $joinTokenValue ? url('/e/'.$joinTokenValue) : null;
|
||||||
|
$qrCodeDataUrl = null;
|
||||||
|
|
||||||
|
if ($qrCodeUrl) {
|
||||||
|
$requestedSize = (int) $request->query('size', 360);
|
||||||
|
$size = max(120, min($requestedSize, 640));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$png = QrCode::format('png')
|
||||||
|
->size($size)
|
||||||
|
->margin(1)
|
||||||
|
->errorCorrection('M')
|
||||||
|
->generate($qrCodeUrl);
|
||||||
|
|
||||||
|
$pngBinary = (string) $png;
|
||||||
|
|
||||||
|
if ($pngBinary !== '') {
|
||||||
|
$qrCodeDataUrl = 'data:image/png;base64,'.base64_encode($pngBinary);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
report($exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'url' => $qrCodeUrl,
|
||||||
|
'qr_code_data_url' => $qrCodeDataUrl,
|
||||||
|
])->header('Cache-Control', 'no-store');
|
||||||
|
}
|
||||||
|
|
||||||
public function package(Request $request, string $token)
|
public function package(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Theme } from '@tamagui/core';
|
||||||
import { Image as ImageIcon, RefreshCcw, UploadCloud, Trash2, ChevronDown, Save, Droplets, Lock } from 'lucide-react';
|
import { Image as ImageIcon, RefreshCcw, UploadCloud, Trash2, ChevronDown, Save, Droplets, Lock } from 'lucide-react';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
@@ -240,6 +241,27 @@ export default function MobileBrandingPage() {
|
|||||||
const previewLogoUrl = previewForm.logoMode === 'upload' ? previewForm.logoDataUrl : '';
|
const previewLogoUrl = previewForm.logoMode === 'upload' ? previewForm.logoDataUrl : '';
|
||||||
const previewLogoValue = previewForm.logoMode === 'emoticon' ? previewForm.logoValue : '';
|
const previewLogoValue = previewForm.logoMode === 'emoticon' ? previewForm.logoValue : '';
|
||||||
const previewInitials = getInitials(previewTitle);
|
const previewInitials = getInitials(previewTitle);
|
||||||
|
const previewThemeName = previewForm.mode === 'dark' ? 'guestNight' : 'guestLight';
|
||||||
|
const previewIsDark = previewThemeName === 'guestNight';
|
||||||
|
const previewVariables = {
|
||||||
|
'--guest-primary': previewForm.primary,
|
||||||
|
'--guest-secondary': previewForm.accent,
|
||||||
|
'--guest-background': previewBackground,
|
||||||
|
'--guest-surface': previewSurface,
|
||||||
|
'--guest-font-scale': String(previewScale),
|
||||||
|
'--guest-body-font': previewBodyFont,
|
||||||
|
'--guest-heading-font': previewHeadingFont,
|
||||||
|
'--guest-button-radius': `${previewForm.buttonRadius}px`,
|
||||||
|
'--guest-radius': `${previewForm.buttonRadius}px`,
|
||||||
|
'--guest-link': previewForm.linkColor,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
const previewAmbient = previewIsDark
|
||||||
|
? 'radial-gradient(circle at 18% 12%, rgba(255, 110, 110, 0.22), transparent 46%), radial-gradient(circle at 82% 18%, rgba(78, 205, 196, 0.18), transparent 44%), linear-gradient(180deg, rgba(6, 9, 20, 0.96), rgba(10, 14, 28, 1))'
|
||||||
|
: 'radial-gradient(circle at 18% 12%, color-mix(in oklab, var(--guest-primary, #FF6B6B) 24%, white), transparent 50%), radial-gradient(circle at 82% 18%, color-mix(in oklab, var(--guest-secondary, #4ECDC4) 20%, white), transparent 48%), linear-gradient(180deg, color-mix(in oklab, var(--guest-background, #FFF5F5) 96%, white), color-mix(in oklab, var(--guest-background, #FFF5F5) 72%, white))';
|
||||||
|
const previewSurfaceShadow = previewIsDark
|
||||||
|
? '0 18px 32px rgba(2, 6, 23, 0.55)'
|
||||||
|
: '0 16px 28px rgba(15, 23, 42, 0.15)';
|
||||||
|
const previewIconSurface = previewIsDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.08)';
|
||||||
const watermarkAllowed = isWatermarkAllowed(event ?? null);
|
const watermarkAllowed = isWatermarkAllowed(event ?? null);
|
||||||
const watermarkRemovalAllowed = isWatermarkRemovalAllowed(event ?? null);
|
const watermarkRemovalAllowed = isWatermarkRemovalAllowed(event ?? null);
|
||||||
const brandingAllowed = isBrandingAllowed(event ?? null);
|
const brandingAllowed = isBrandingAllowed(event ?? null);
|
||||||
@@ -616,79 +638,158 @@ export default function MobileBrandingPage() {
|
|||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.branding.previewTitle', 'Guest App Preview')}
|
{t('events.branding.previewTitle', 'Guest App Preview')}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack borderRadius={16} borderWidth={1} borderColor={previewBorder} backgroundColor={previewBackground} padding="$3" gap="$2" alignItems="center">
|
<Theme name={previewThemeName}>
|
||||||
<YStack width="100%" borderRadius={12} backgroundColor={previewSurface} borderWidth={1} borderColor={previewBorder} overflow="hidden">
|
<YStack
|
||||||
|
borderRadius={18}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={previewBorder}
|
||||||
|
overflow="hidden"
|
||||||
|
style={previewVariables}
|
||||||
|
>
|
||||||
<YStack
|
<YStack
|
||||||
height={64}
|
padding="$3"
|
||||||
style={{ background: `linear-gradient(135deg, ${previewForm.primary}, ${previewForm.accent})` }}
|
gap="$3"
|
||||||
/>
|
style={{
|
||||||
<YStack padding="$3" gap="$2">
|
backgroundImage: previewAmbient,
|
||||||
<XStack
|
backgroundSize: '140% 140%, 140% 140%, 100% 100%',
|
||||||
alignItems="center"
|
}}
|
||||||
gap="$2"
|
>
|
||||||
flexDirection={previewForm.logoPosition === 'center' ? 'column' : previewForm.logoPosition === 'right' ? 'row-reverse' : 'row'}
|
<XStack alignItems="center" justifyContent="space-between" gap="$3">
|
||||||
justifyContent={previewForm.logoPosition === 'center' ? 'center' : 'flex-start'}
|
<XStack
|
||||||
>
|
|
||||||
<YStack
|
|
||||||
width={previewLogoSize}
|
|
||||||
height={previewLogoSize}
|
|
||||||
borderRadius={previewLogoSize}
|
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
gap="$2"
|
||||||
backgroundColor={previewForm.accent}
|
flexDirection={previewForm.logoPosition === 'center' ? 'column' : previewForm.logoPosition === 'right' ? 'row-reverse' : 'row'}
|
||||||
|
justifyContent={previewForm.logoPosition === 'center' ? 'center' : 'flex-start'}
|
||||||
|
flex={1}
|
||||||
>
|
>
|
||||||
{previewLogoUrl ? (
|
<YStack
|
||||||
<img
|
width={previewLogoSize}
|
||||||
src={previewLogoUrl}
|
height={previewLogoSize}
|
||||||
alt={t('events.branding.logoAlt', 'Logo')}
|
borderRadius={previewLogoSize}
|
||||||
style={{ width: previewLogoSize - 6, height: previewLogoSize - 6, borderRadius: previewLogoSize, objectFit: 'cover' }}
|
alignItems="center"
|
||||||
/>
|
justifyContent="center"
|
||||||
) : (
|
backgroundColor={previewForm.accent}
|
||||||
<Text fontSize="$sm" color={previewSurfaceText} fontWeight="700">
|
|
||||||
{previewLogoValue || previewInitials}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</YStack>
|
|
||||||
<YStack>
|
|
||||||
<Text
|
|
||||||
fontWeight="800"
|
|
||||||
color={previewSurfaceText}
|
|
||||||
style={{ fontFamily: previewHeadingFont, fontSize: 18 * previewScale }}
|
|
||||||
>
|
>
|
||||||
{previewTitle}
|
{previewLogoUrl ? (
|
||||||
|
<img
|
||||||
|
src={previewLogoUrl}
|
||||||
|
alt={t('events.branding.logoAlt', 'Logo')}
|
||||||
|
style={{ width: previewLogoSize - 6, height: previewLogoSize - 6, borderRadius: previewLogoSize, objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="$sm" color={previewSurfaceText} fontWeight="700">
|
||||||
|
{previewLogoValue || previewInitials}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</YStack>
|
||||||
|
<YStack gap="$1">
|
||||||
|
<Text
|
||||||
|
fontWeight="800"
|
||||||
|
color={previewSurfaceText}
|
||||||
|
style={{ fontFamily: previewHeadingFont, fontSize: 18 * previewScale }}
|
||||||
|
>
|
||||||
|
{previewTitle}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
color={previewMutedForeground}
|
||||||
|
style={{ fontFamily: previewBodyFont, fontSize: 13 * previewScale }}
|
||||||
|
>
|
||||||
|
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
</XStack>
|
||||||
|
<XStack gap="$2">
|
||||||
|
<YStack width={28} height={28} borderRadius={999} backgroundColor={previewIconSurface} />
|
||||||
|
<YStack width={28} height={28} borderRadius={999} backgroundColor={previewIconSurface} />
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<YStack gap="$2">
|
||||||
|
<YStack
|
||||||
|
padding="$3"
|
||||||
|
borderRadius={14}
|
||||||
|
backgroundColor={previewSurface}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={previewBorder}
|
||||||
|
style={{ boxShadow: previewSurfaceShadow }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
fontWeight="700"
|
||||||
|
color={previewSurfaceText}
|
||||||
|
style={{ fontFamily: previewHeadingFont, fontSize: 14 * previewScale }}
|
||||||
|
>
|
||||||
|
{t('events.branding.previewTitleShort', 'Dein Event-Hub')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
color={previewMutedForeground}
|
color={previewMutedForeground}
|
||||||
style={{ fontFamily: previewBodyFont, fontSize: 13 * previewScale }}
|
style={{ fontFamily: previewBodyFont, fontSize: 12 * previewScale }}
|
||||||
>
|
>
|
||||||
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
|
{t('events.branding.previewSubtitleShort', 'Gäste, Fotos, Highlights')}
|
||||||
</Text>
|
</Text>
|
||||||
|
<XStack gap="$2" marginTop="$2">
|
||||||
|
<ColorSwatch color={previewForm.primary} label={t('events.branding.primary', 'Primary')} borderColor={previewBorder} />
|
||||||
|
<ColorSwatch color={previewForm.accent} label={t('events.branding.accent', 'Accent')} borderColor={previewBorder} />
|
||||||
|
<ColorSwatch color={previewBackground} label={t('events.branding.background', 'Background')} borderColor={previewBorder} />
|
||||||
|
<ColorSwatch color={previewSurface} label={t('events.branding.surface', 'Surface')} borderColor={previewBorder} />
|
||||||
|
</XStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
</XStack>
|
|
||||||
<XStack gap="$2" marginTop="$1">
|
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||||
<ColorSwatch color={previewForm.primary} label={t('events.branding.primary', 'Primary')} borderColor={previewBorder} />
|
<YStack
|
||||||
<ColorSwatch color={previewForm.accent} label={t('events.branding.accent', 'Accent')} borderColor={previewBorder} />
|
padding="$2"
|
||||||
<ColorSwatch color={previewBackground} label={t('events.branding.background', 'Background')} borderColor={previewBorder} />
|
borderRadius="$pill"
|
||||||
<ColorSwatch color={previewSurface} label={t('events.branding.surface', 'Surface')} borderColor={previewBorder} />
|
backgroundColor={previewSurface}
|
||||||
</XStack>
|
borderWidth={1}
|
||||||
<XStack marginTop="$2">
|
borderColor={previewBorder}
|
||||||
<div
|
flex={1}
|
||||||
style={{
|
>
|
||||||
padding: '8px 14px',
|
<Text fontSize="$2" color={previewMutedForeground}>
|
||||||
borderRadius: previewForm.buttonRadius,
|
{t('events.branding.previewStat', 'Online Guests')}
|
||||||
background: previewForm.buttonStyle === 'outline' ? 'transparent' : previewButtonColor,
|
</Text>
|
||||||
color: previewForm.buttonStyle === 'outline' ? previewForm.linkColor : previewButtonText,
|
<Text fontWeight="800" color={previewSurfaceText} style={{ fontSize: 14 * previewScale }}>
|
||||||
border: previewForm.buttonStyle === 'outline' ? `1px solid ${previewForm.linkColor}` : 'none',
|
148
|
||||||
fontWeight: 700,
|
</Text>
|
||||||
fontSize: 13 * previewScale,
|
</YStack>
|
||||||
}}
|
<div
|
||||||
>
|
style={{
|
||||||
{t('events.branding.previewCta', 'Fotos hochladen')}
|
padding: '8px 14px',
|
||||||
</div>
|
borderRadius: previewForm.buttonRadius,
|
||||||
|
background: previewForm.buttonStyle === 'outline' ? 'transparent' : previewButtonColor,
|
||||||
|
color: previewForm.buttonStyle === 'outline' ? previewForm.linkColor : previewButtonText,
|
||||||
|
border: previewForm.buttonStyle === 'outline' ? `1px solid ${previewForm.linkColor}` : 'none',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 13 * previewScale,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('events.branding.previewCta', 'Fotos hochladen')}
|
||||||
|
</div>
|
||||||
|
</XStack>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
gap="$2"
|
||||||
|
padding="$2"
|
||||||
|
borderRadius="$pill"
|
||||||
|
backgroundColor={previewSurface}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={previewBorder}
|
||||||
|
>
|
||||||
|
<XStack gap="$2">
|
||||||
|
<YStack width={28} height={28} borderRadius={999} backgroundColor={previewIconSurface} />
|
||||||
|
<YStack width={28} height={28} borderRadius={999} backgroundColor={previewIconSurface} />
|
||||||
|
<YStack width={28} height={28} borderRadius={999} backgroundColor={previewIconSurface} />
|
||||||
|
</XStack>
|
||||||
|
<YStack width={36} height={36} borderRadius={999} backgroundColor={previewButtonColor} />
|
||||||
|
<XStack gap="$2">
|
||||||
|
<YStack width={28} height={28} borderRadius={999} backgroundColor={previewIconSurface} />
|
||||||
|
<YStack width={28} height={28} borderRadius={999} backgroundColor={previewIconSurface} />
|
||||||
|
</XStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
</YStack>
|
</Theme>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
{!brandingAllowed ? (
|
{!brandingAllowed ? (
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ vi.mock('@tamagui/text', () => ({
|
|||||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/core', () => ({
|
||||||
|
Theme: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||||
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
|
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||||
<button type="button" onClick={onPress}>
|
<button type="button" onClick={onPress}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { router } from './router';
|
|||||||
import { ConsentProvider } from '@/contexts/consent';
|
import { ConsentProvider } from '@/contexts/consent';
|
||||||
import { AppearanceProvider } from '@/hooks/use-appearance';
|
import { AppearanceProvider } from '@/hooks/use-appearance';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useAppearance } from '@/hooks/use-appearance';
|
||||||
|
import ToastHost from './components/ToastHost';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -26,6 +27,7 @@ function AppThemeRouter() {
|
|||||||
return (
|
return (
|
||||||
<Theme name={themeName}>
|
<Theme name={themeName}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
<ToastHost />
|
||||||
</Theme>
|
</Theme>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
59
resources/js/guest-v2/__tests__/EventLogo.test.tsx
Normal file
59
resources/js/guest-v2/__tests__/EventLogo.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import EventLogo from '../components/EventLogo';
|
||||||
|
import { EventBrandingProvider } from '@/guest/context/EventBrandingContext';
|
||||||
|
import type { EventBranding } from '@/guest/types/event-branding';
|
||||||
|
|
||||||
|
vi.mock('@tamagui/stacks', () => ({
|
||||||
|
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/text', () => ({
|
||||||
|
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseBranding: EventBranding = {
|
||||||
|
primaryColor: '#ff5a5f',
|
||||||
|
secondaryColor: '#fbcfe8',
|
||||||
|
backgroundColor: '#fff7f5',
|
||||||
|
fontFamily: null,
|
||||||
|
logoUrl: null,
|
||||||
|
logo: {
|
||||||
|
mode: 'emoticon',
|
||||||
|
value: '🎉',
|
||||||
|
size: 'm',
|
||||||
|
},
|
||||||
|
mode: 'auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('EventLogo', () => {
|
||||||
|
it('renders an uploaded logo image when configured', () => {
|
||||||
|
const branding: EventBranding = {
|
||||||
|
...baseBranding,
|
||||||
|
logo: {
|
||||||
|
mode: 'upload',
|
||||||
|
value: 'https://example.com/logo.png',
|
||||||
|
size: 'm',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<EventBrandingProvider branding={branding}>
|
||||||
|
<EventLogo name="Demo Event" />
|
||||||
|
</EventBrandingProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByAltText('Demo Event')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an emoji fallback when configured', () => {
|
||||||
|
render(
|
||||||
|
<EventBrandingProvider branding={baseBranding}>
|
||||||
|
<EventLogo name="Demo Event" />
|
||||||
|
</EventBrandingProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('🎉')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,8 +32,12 @@ vi.mock('../components/SurfaceCard', () => ({
|
|||||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/ShareSheet', () => ({
|
||||||
|
default: () => <div>ShareSheet</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../context/EventDataContext', () => ({
|
vi.mock('../context/EventDataContext', () => ({
|
||||||
useEventData: () => ({ token: 'token' }),
|
useEventData: () => ({ token: 'token', event: { name: 'Demo Event' } }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../services/photosApi', () => ({
|
vi.mock('../services/photosApi', () => ({
|
||||||
|
|||||||
@@ -103,6 +103,14 @@ vi.mock('../services/emotionsApi', () => ({
|
|||||||
fetchEmotions: vi.fn().mockResolvedValue([]),
|
fetchEmotions: vi.fn().mockResolvedValue([]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../hooks/usePollStats', () => ({
|
||||||
|
usePollStats: () => ({ stats: { onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/qrApi', () => ({
|
||||||
|
fetchEventQrCode: () => Promise.resolve({ qr_code_data_url: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
|
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
|
||||||
useGuestTaskProgress: () => ({ completedCount: 0 }),
|
useGuestTaskProgress: () => ({ completedCount: 0 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
63
resources/js/guest-v2/__tests__/ShareScreen.test.tsx
Normal file
63
resources/js/guest-v2/__tests__/ShareScreen.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
vi.mock('@tamagui/stacks', () => ({
|
||||||
|
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/text', () => ({
|
||||||
|
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/button', () => ({
|
||||||
|
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
|
||||||
|
<button type="button" {...rest}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/AppShell', () => ({
|
||||||
|
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../context/EventDataContext', () => ({
|
||||||
|
useEventData: () => ({
|
||||||
|
token: 'demo-token',
|
||||||
|
event: { name: 'Demo Event', join_token: 'demo-token' },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../hooks/usePollStats', () => ({
|
||||||
|
usePollStats: () => ({ stats: { onlineGuests: 12, tasksSolved: 0, latestPhotoAt: null } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/qrApi', () => ({
|
||||||
|
fetchEventQrCode: () => Promise.resolve({ qr_code_data_url: 'data:image/png;base64,abc' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
|
||||||
|
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/use-appearance', () => ({
|
||||||
|
useAppearance: () => ({ resolved: 'light' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ShareScreen from '../screens/ShareScreen';
|
||||||
|
|
||||||
|
describe('ShareScreen', () => {
|
||||||
|
it('shows guests online from stats', async () => {
|
||||||
|
render(<ShareScreen />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('12')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Guests joined')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Guests online')).toBeInTheDocument();
|
||||||
|
expect(screen.getByAltText('Event QR code')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { YStack } from '@tamagui/stacks';
|
import { YStack } from '@tamagui/stacks';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
type AmbientBackgroundProps = {
|
type AmbientBackgroundProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AmbientBackground({ children }: AmbientBackgroundProps) {
|
export default function AmbientBackground({ children }: AmbientBackgroundProps) {
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack
|
<YStack
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useEventData } from '../context/EventDataContext';
|
|||||||
import { buildEventPath } from '../lib/routes';
|
import { buildEventPath } from '../lib/routes';
|
||||||
import { useOptionalNotificationCenter } from '@/guest/context/NotificationCenterContext';
|
import { useOptionalNotificationCenter } from '@/guest/context/NotificationCenterContext';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
type AppShellProps = {
|
type AppShellProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -31,8 +31,7 @@ export default function AppShell({ children }: AppShellProps) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const actionIconColor = isDark ? '#F8FAFF' : '#0F172A';
|
const actionIconColor = isDark ? '#F8FAFF' : '#0F172A';
|
||||||
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
|
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
|
||||||
const showFab = !/\/photo\/\d+/.test(location.pathname);
|
const showFab = !/\/photo\/\d+/.test(location.pathname);
|
||||||
@@ -114,6 +113,7 @@ export default function AppShell({ children }: AppShellProps) {
|
|||||||
>
|
>
|
||||||
<TopBar
|
<TopBar
|
||||||
eventName={event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
|
eventName={event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
|
||||||
|
eventIcon={event?.type?.icon ?? null}
|
||||||
onProfilePress={() => {
|
onProfilePress={() => {
|
||||||
setNotificationsOpen(false);
|
setNotificationsOpen(false);
|
||||||
setCompassOpen(false);
|
setCompassOpen(false);
|
||||||
|
|||||||
@@ -7,15 +7,14 @@ import { Home, Image, Share2 } from 'lucide-react';
|
|||||||
import { useEventData } from '../context/EventDataContext';
|
import { useEventData } from '../context/EventDataContext';
|
||||||
import { buildEventPath } from '../lib/routes';
|
import { buildEventPath } from '../lib/routes';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
export default function BottomDock() {
|
export default function BottomDock() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { token } = useEventData();
|
const { token } = useEventData();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
|
|
||||||
const dockItems = [
|
const dockItems = [
|
||||||
{ key: 'home', label: t('navigation.home', 'Home'), path: '/', icon: Home },
|
{ key: 'home', label: t('navigation.home', 'Home'), path: '/', icon: Home },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { YStack } from '@tamagui/stacks';
|
import { YStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Button } from '@tamagui/button';
|
import { Button } from '@tamagui/button';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
export type CompassAction = {
|
export type CompassAction = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -39,8 +39,7 @@ export default function CompassHub({
|
|||||||
title = 'Quick jump',
|
title = 'Quick jump',
|
||||||
}: CompassHubProps) {
|
}: CompassHubProps) {
|
||||||
const close = () => onOpenChange(false);
|
const close = () => onOpenChange(false);
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const [visible, setVisible] = React.useState(open);
|
const [visible, setVisible] = React.useState(open);
|
||||||
const [closing, setClosing] = React.useState(false);
|
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 { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { Button } from '@tamagui/button';
|
import { Button } from '@tamagui/button';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
type FabAction = {
|
type FabAction = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -25,8 +25,7 @@ const positions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function FabActionRing({ open, onOpenChange, actions }: FabActionRingProps) {
|
export default function FabActionRing({ open, onOpenChange, actions }: FabActionRingProps) {
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const borderColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(15, 23, 42, 0.12)';
|
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 surfaceColor = isDark ? 'rgba(12, 16, 32, 0.92)' : 'rgba(255, 255, 255, 0.95)';
|
||||||
const textColor = isDark ? '#F8FAFF' : '#0F172A';
|
const textColor = isDark ? '#F8FAFF' : '#0F172A';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { YStack, XStack } from '@tamagui/stacks';
|
|||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Button } from '@tamagui/button';
|
import { Button } from '@tamagui/button';
|
||||||
import { Sheet } from '@tamagui/sheet';
|
import { Sheet } from '@tamagui/sheet';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
export type FabAction = {
|
export type FabAction = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -21,8 +21,7 @@ type FabActionSheetProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function FabActionSheet({ open, onOpenChange, title, actions }: FabActionSheetProps) {
|
export default function FabActionSheet({ open, onOpenChange, title, actions }: FabActionSheetProps) {
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from '@tamagui/button';
|
import { Button } from '@tamagui/button';
|
||||||
import { Flower } from 'lucide-react';
|
import { Flower } from 'lucide-react';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
type FloatingActionButtonProps = {
|
type FloatingActionButtonProps = {
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
@@ -10,8 +10,7 @@ type FloatingActionButtonProps = {
|
|||||||
|
|
||||||
export default function FloatingActionButton({ onPress, onLongPress }: FloatingActionButtonProps) {
|
export default function FloatingActionButton({ onPress, onLongPress }: FloatingActionButtonProps) {
|
||||||
const longPressTriggered = React.useRef(false);
|
const longPressTriggered = React.useRef(false);
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Button } from '@tamagui/button';
|
|||||||
import { useConsent } from '@/contexts/consent';
|
import { useConsent } from '@/contexts/consent';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { isUploadPath, shouldShowAnalyticsNudge } from '@/guest/lib/analyticsConsent';
|
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 PROMPT_STORAGE_KEY = 'fotospiel.guest.analyticsPrompt';
|
||||||
const SNOOZE_MS = 60 * 60 * 1000;
|
const SNOOZE_MS = 60 * 60 * 1000;
|
||||||
@@ -70,8 +70,7 @@ export default function GuestAnalyticsNudge({
|
|||||||
const lastPathRef = React.useRef(pathname);
|
const lastPathRef = React.useRef(pathname);
|
||||||
const lastActivityAtRef = React.useRef(Date.now());
|
const lastActivityAtRef = React.useRef(Date.now());
|
||||||
const visibleRef = React.useRef(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
|
const visibleRef = React.useRef(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
|
|
||||||
const isUpload = isUploadPath(pathname);
|
const isUpload = isUploadPath(pathname);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ScrollView } from '@tamagui/scroll-view';
|
|||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useOptionalNotificationCenter } from '@/guest/context/NotificationCenterContext';
|
import { useOptionalNotificationCenter } from '@/guest/context/NotificationCenterContext';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
type NotificationSheetProps = {
|
type NotificationSheetProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -16,8 +16,7 @@ type NotificationSheetProps = {
|
|||||||
export default function NotificationSheet({ open, onOpenChange }: NotificationSheetProps) {
|
export default function NotificationSheet({ open, onOpenChange }: NotificationSheetProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const center = useOptionalNotificationCenter();
|
const center = useOptionalNotificationCenter();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
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 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 }) {
|
function InfoBadge({ label, value }: { label: string; value: number }) {
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack
|
<YStack
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { YStack } from '@tamagui/stacks';
|
import { YStack } from '@tamagui/stacks';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
type PhotoFrameTileProps = {
|
type PhotoFrameTileProps = {
|
||||||
height: number;
|
height: number;
|
||||||
@@ -17,8 +17,7 @@ export default function PhotoFrameTile({
|
|||||||
shimmer = false,
|
shimmer = false,
|
||||||
shimmerDelayMs = 0,
|
shimmerDelayMs = 0,
|
||||||
}: PhotoFrameTileProps) {
|
}: PhotoFrameTileProps) {
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack
|
<YStack
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useConsent } from '@/contexts/consent';
|
|||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useAppearance } from '@/hooks/use-appearance';
|
||||||
import { useEventData } from '../context/EventDataContext';
|
import { useEventData } from '../context/EventDataContext';
|
||||||
import { buildEventPath } from '../lib/routes';
|
import { buildEventPath } from '../lib/routes';
|
||||||
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
const legalLinks = [
|
const legalLinks = [
|
||||||
{ slug: 'impressum', labelKey: 'settings.legal.section.impressum', fallback: 'Impressum' },
|
{ 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 { preferences, savePreferences } = useConsent();
|
||||||
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
|
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
|
const { isDark } = useGuestThemeVariant();
|
||||||
const { token } = useEventData();
|
const { token } = useEventData();
|
||||||
const isDark = appearance === 'dark';
|
|
||||||
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.65)' : 'rgba(255, 255, 255, 0.82)';
|
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 cardBorder = isDark ? 'rgba(148, 163, 184, 0.18)' : 'rgba(15, 23, 42, 0.12)';
|
||||||
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
|
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
|
||||||
@@ -307,8 +308,7 @@ function ClearCacheButton() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [busy, setBusy] = React.useState(false);
|
const [busy, setBusy] = React.useState(false);
|
||||||
const [done, setDone] = React.useState(false);
|
const [done, setDone] = React.useState(false);
|
||||||
const { appearance } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = appearance === 'dark';
|
|
||||||
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
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 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)';
|
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 { Button } from '@tamagui/button';
|
||||||
import { ArrowLeft, X } from 'lucide-react';
|
import { ArrowLeft, X } from 'lucide-react';
|
||||||
import SettingsContent from './SettingsContent';
|
import SettingsContent from './SettingsContent';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||||
import { LegalMarkdown } from '@/guest/components/legal-markdown';
|
import { LegalMarkdown } from '@/guest/components/legal-markdown';
|
||||||
@@ -35,8 +35,7 @@ type SettingsSheetProps = {
|
|||||||
export default function SettingsSheet({ open, onOpenChange }: SettingsSheetProps) {
|
export default function SettingsSheet({ open, onOpenChange }: SettingsSheetProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const [view, setView] = React.useState<ViewState>({ mode: 'home' });
|
const [view, setView] = React.useState<ViewState>({ mode: 'home' });
|
||||||
const isLegal = view.mode === 'legal';
|
const isLegal = view.mode === 'legal';
|
||||||
const legalDocument = useLegalDocument(isLegal ? view.slug : null, locale);
|
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 }) {
|
function LegalView({ document, fallbackTitle }: { document: LegalDocumentState; fallbackTitle: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
|
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
|
||||||
|
|
||||||
if (document.phase === 'error') {
|
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 React from 'react';
|
||||||
import { YStack } from '@tamagui/stacks';
|
import { YStack } from '@tamagui/stacks';
|
||||||
import type { YStackProps } from '@tamagui/stacks';
|
import type { YStackProps } from '@tamagui/stacks';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
type SurfaceCardProps = YStackProps & {
|
type SurfaceCardProps = YStackProps & {
|
||||||
glow?: boolean;
|
glow?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SurfaceCard({ children, glow = false, ...props }: SurfaceCardProps) {
|
export default function SurfaceCard({ children, glow = false, ...props }: SurfaceCardProps) {
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const borderColor = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
const borderColor = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||||
const boxShadow = isDark
|
const boxShadow = isDark
|
||||||
? glow
|
? glow
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Camera, CheckCircle2, Heart, RefreshCw, Sparkles, Timer as TimerIcon }
|
|||||||
import PhotoFrameTile from './PhotoFrameTile';
|
import PhotoFrameTile from './PhotoFrameTile';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '@/guest/lib/emotionTheme';
|
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';
|
import { getBentoSurfaceTokens } from '../lib/bento';
|
||||||
|
|
||||||
type TaskHeroEmotion = EmotionIdentity & { emoji?: string | null };
|
type TaskHeroEmotion = EmotionIdentity & { emoji?: string | null };
|
||||||
@@ -65,8 +65,7 @@ export default function TaskHeroCard({
|
|||||||
const heroCardRef = React.useRef<HTMLDivElement | null>(null);
|
const heroCardRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const theme = getEmotionTheme(task?.emotion ?? null);
|
const theme = getEmotionTheme(task?.emotion ?? null);
|
||||||
const emotionIcon = getEmotionIcon(task?.emotion ?? null);
|
const emotionIcon = getEmotionIcon(task?.emotion ?? null);
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const bentoSurface = getBentoSurfaceTokens(isDark);
|
const bentoSurface = getBentoSurfaceTokens(isDark);
|
||||||
|
|
||||||
React.useEffect(() => {
|
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 { SizableText as Text } from '@tamagui/text';
|
||||||
import { Button } from '@tamagui/button';
|
import { Button } from '@tamagui/button';
|
||||||
import { Bell, Settings } from 'lucide-react';
|
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 = {
|
type TopBarProps = {
|
||||||
eventName: string;
|
eventName: string;
|
||||||
|
eventIcon?: string | null;
|
||||||
onProfilePress?: () => void;
|
onProfilePress?: () => void;
|
||||||
onNotificationsPress?: () => void;
|
onNotificationsPress?: () => void;
|
||||||
notificationCount?: number;
|
notificationCount?: number;
|
||||||
@@ -14,18 +17,28 @@ type TopBarProps = {
|
|||||||
|
|
||||||
export default function TopBar({
|
export default function TopBar({
|
||||||
eventName,
|
eventName,
|
||||||
|
eventIcon,
|
||||||
onProfilePress,
|
onProfilePress,
|
||||||
onNotificationsPress,
|
onNotificationsPress,
|
||||||
notificationCount = 0,
|
notificationCount = 0,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
const brandingContext = useOptionalEventBranding();
|
||||||
|
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
||||||
|
const logoPosition = branding.logo?.position ?? 'left';
|
||||||
const [animationKey, setAnimationKey] = React.useState(0);
|
const [animationKey, setAnimationKey] = React.useState(0);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setAnimationKey((prev) => prev + 1);
|
setAnimationKey((prev) => prev + 1);
|
||||||
}, [eventName]);
|
}, [eventName]);
|
||||||
|
|
||||||
|
const identityDirection = logoPosition === 'right'
|
||||||
|
? 'row-reverse'
|
||||||
|
: logoPosition === 'center'
|
||||||
|
? 'column'
|
||||||
|
: 'row';
|
||||||
|
const identityAlign = logoPosition === 'center' ? 'center' : 'flex-start';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
@@ -38,19 +51,29 @@ export default function TopBar({
|
|||||||
WebkitBackdropFilter: 'saturate(120%) blur(8px)',
|
WebkitBackdropFilter: 'saturate(120%) blur(8px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<XStack
|
||||||
key={animationKey}
|
key={animationKey}
|
||||||
fontSize="$8"
|
alignItems={identityAlign}
|
||||||
fontFamily="$display"
|
gap="$2"
|
||||||
fontWeight="$8"
|
flexDirection={identityDirection}
|
||||||
numberOfLines={1}
|
|
||||||
className="guest-topbar-title"
|
|
||||||
flexShrink={1}
|
flexShrink={1}
|
||||||
minWidth={0}
|
minWidth={0}
|
||||||
style={{ fontSize: 'clamp(20px, 4.6vw, 30px)', lineHeight: '1.1' }}
|
|
||||||
>
|
>
|
||||||
{eventName}
|
<EventLogo name={eventName} icon={eventIcon} logo={branding.logo} size="s" />
|
||||||
</Text>
|
<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">
|
<XStack gap="$2" alignItems="center">
|
||||||
<Button
|
<Button
|
||||||
size="$3"
|
size="$3"
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ function resolveThemeVariant(
|
|||||||
? 'dark'
|
? 'dark'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (appearanceOverride) {
|
||||||
|
return appearanceOverride;
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === 'dark') {
|
if (mode === 'dark') {
|
||||||
return 'dark';
|
return 'dark';
|
||||||
}
|
}
|
||||||
@@ -36,10 +40,6 @@ function resolveThemeVariant(
|
|||||||
return 'light';
|
return 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appearanceOverride) {
|
|
||||||
return appearanceOverride;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (backgroundPrefers) {
|
if (backgroundPrefers) {
|
||||||
return backgroundPrefers;
|
return backgroundPrefers;
|
||||||
}
|
}
|
||||||
|
|||||||
28
resources/js/guest-v2/lib/guestTheme.ts
Normal file
28
resources/js/guest-v2/lib/guestTheme.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '@/guest/context/EventBrandingContext';
|
||||||
|
import type { EventBranding } from '@/guest/types/event-branding';
|
||||||
|
import { useAppearance, type Appearance } from '@/hooks/use-appearance';
|
||||||
|
import { resolveGuestThemeName } from './brandingTheme';
|
||||||
|
|
||||||
|
export function resolveGuestThemeVariant(branding: EventBranding, appearance: Appearance): 'light' | 'dark' {
|
||||||
|
const themeName = resolveGuestThemeName(branding, appearance);
|
||||||
|
return themeName === 'guestNight' ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGuestThemeVariant(brandingOverride?: EventBranding | null): {
|
||||||
|
isDark: boolean;
|
||||||
|
variant: 'light' | 'dark';
|
||||||
|
themeName: 'guestLight' | 'guestNight';
|
||||||
|
branding: EventBranding;
|
||||||
|
} {
|
||||||
|
const { appearance, resolved } = useAppearance() as {
|
||||||
|
appearance?: Appearance;
|
||||||
|
resolved?: 'light' | 'dark';
|
||||||
|
};
|
||||||
|
const brandingContext = useOptionalEventBranding();
|
||||||
|
const branding = brandingOverride ?? brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
||||||
|
const appearanceMode = appearance ?? (resolved === 'dark' ? 'dark' : 'light');
|
||||||
|
const themeName = resolveGuestThemeName(branding, appearanceMode);
|
||||||
|
const variant = themeName === 'guestNight' ? 'dark' : 'light';
|
||||||
|
|
||||||
|
return { isDark: variant === 'dark', variant, themeName, branding };
|
||||||
|
}
|
||||||
18
resources/js/guest-v2/lib/toast.ts
Normal file
18
resources/js/guest-v2/lib/toast.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export type GuestToastPayload = {
|
||||||
|
text: string;
|
||||||
|
type?: 'success' | 'error' | 'info';
|
||||||
|
action?: { label: string; onClick: () => void };
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pushGuestToast(detail: GuestToastPayload) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new CustomEvent('guest-toast', { detail }));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Dispatching toast event failed', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,15 +8,14 @@ import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
|||||||
import { fetchAchievements, type AchievementsPayload } from '../services/achievementsApi';
|
import { fetchAchievements, type AchievementsPayload } from '../services/achievementsApi';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
export default function AchievementsScreen() {
|
export default function AchievementsScreen() {
|
||||||
const { token } = useEventData();
|
const { token } = useEventData();
|
||||||
const identity = useOptionalGuestIdentity();
|
const identity = useOptionalGuestIdentity();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||||
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
|
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
|
||||||
const [payload, setPayload] = React.useState<AchievementsPayload | null>(null);
|
const [payload, setPayload] = React.useState<AchievementsPayload | null>(null);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import PhotoFrameTile from '../components/PhotoFrameTile';
|
|||||||
import { useEventData } from '../context/EventDataContext';
|
import { useEventData } from '../context/EventDataContext';
|
||||||
import { fetchGallery } from '../services/photosApi';
|
import { fetchGallery } from '../services/photosApi';
|
||||||
import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
|
import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -47,8 +47,7 @@ export default function GalleryScreen() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||||
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
|
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
|
||||||
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
||||||
|
|||||||
@@ -11,19 +11,21 @@ import PullToRefresh from '@/guest/components/PullToRefresh';
|
|||||||
import { getHelpArticle, type HelpArticleDetail } from '@/guest/services/helpApi';
|
import { getHelpArticle, type HelpArticleDetail } from '@/guest/services/helpApi';
|
||||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import EventLogo from '../components/EventLogo';
|
||||||
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
export default function HelpArticleScreen() {
|
export default function HelpArticleScreen() {
|
||||||
const params = useParams<{ token?: string; slug: string }>();
|
const params = useParams<{ token?: string; slug: string }>();
|
||||||
const slug = params.slug;
|
const slug = params.slug;
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
||||||
const [article, setArticle] = React.useState<HelpArticleDetail | null>(null);
|
const [article, setArticle] = React.useState<HelpArticleDetail | null>(null);
|
||||||
const [state, setState] = React.useState<'loading' | 'ready' | 'error'>('loading');
|
const [state, setState] = React.useState<'loading' | 'ready' | 'error'>('loading');
|
||||||
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
|
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
|
||||||
|
const brandName = 'Fotospiel';
|
||||||
|
const showStandaloneHeader = !params.token;
|
||||||
|
|
||||||
const loadArticle = React.useCallback(async () => {
|
const loadArticle = React.useCallback(async () => {
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
@@ -78,14 +80,19 @@ export default function HelpArticleScreen() {
|
|||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
|
|
||||||
<SurfaceCard glow>
|
<SurfaceCard glow>
|
||||||
<Text fontSize="$5" fontWeight="$8">
|
<XStack alignItems="center" gap="$3">
|
||||||
{title}
|
{showStandaloneHeader ? <EventLogo name={brandName} size="s" /> : null}
|
||||||
</Text>
|
<YStack gap="$1">
|
||||||
{article?.updated_at ? (
|
<Text fontSize="$5" fontWeight="$8">
|
||||||
<Text fontSize="$2" color={mutedText} marginTop="$2">
|
{title}
|
||||||
{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}
|
</Text>
|
||||||
</Text>
|
{article?.updated_at ? (
|
||||||
) : null}
|
<Text fontSize="$2" color={mutedText}>
|
||||||
|
{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
</XStack>
|
||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
|
|
||||||
{state === 'loading' ? (
|
{state === 'loading' ? (
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import PullToRefresh from '@/guest/components/PullToRefresh';
|
|||||||
import { getHelpArticles, type HelpArticleSummary } from '@/guest/services/helpApi';
|
import { getHelpArticles, type HelpArticleSummary } from '@/guest/services/helpApi';
|
||||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import EventLogo from '../components/EventLogo';
|
||||||
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
export default function HelpCenterScreen() {
|
export default function HelpCenterScreen() {
|
||||||
const params = useParams<{ token?: string }>();
|
const params = useParams<{ token?: string }>();
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
||||||
const [articles, setArticles] = React.useState<HelpArticleSummary[]>([]);
|
const [articles, setArticles] = React.useState<HelpArticleSummary[]>([]);
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
@@ -27,6 +27,8 @@ export default function HelpCenterScreen() {
|
|||||||
const [servedFromCache, setServedFromCache] = React.useState(false);
|
const [servedFromCache, setServedFromCache] = React.useState(false);
|
||||||
const [isOnline, setIsOnline] = React.useState(() => (typeof navigator !== 'undefined' ? navigator.onLine : true));
|
const [isOnline, setIsOnline] = React.useState(() => (typeof navigator !== 'undefined' ? navigator.onLine : true));
|
||||||
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
|
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
|
||||||
|
const brandName = 'Fotospiel';
|
||||||
|
const showStandaloneHeader = !params.token;
|
||||||
|
|
||||||
const loadArticles = React.useCallback(async (forceRefresh = false) => {
|
const loadArticles = React.useCallback(async (forceRefresh = false) => {
|
||||||
setState('loading');
|
setState('loading');
|
||||||
@@ -73,12 +75,17 @@ export default function HelpCenterScreen() {
|
|||||||
>
|
>
|
||||||
<YStack gap="$4">
|
<YStack gap="$4">
|
||||||
<SurfaceCard glow>
|
<SurfaceCard glow>
|
||||||
<Text fontSize="$5" fontWeight="$8">
|
<XStack alignItems="center" gap="$3">
|
||||||
{t('help.center.title')}
|
{showStandaloneHeader ? <EventLogo name={brandName} size="s" /> : null}
|
||||||
</Text>
|
<YStack gap="$1">
|
||||||
<Text fontSize="$2" color={mutedText} marginTop="$2">
|
<Text fontSize="$5" fontWeight="$8">
|
||||||
{t('help.center.subtitle')}
|
{t('help.center.title')}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text fontSize="$2" color={mutedText}>
|
||||||
|
{t('help.center.subtitle')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
</XStack>
|
||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
|
|
||||||
<SurfaceCard>
|
<SurfaceCard>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { usePollStats } from '../hooks/usePollStats';
|
|||||||
import { fetchGallery } from '../services/photosApi';
|
import { fetchGallery } from '../services/photosApi';
|
||||||
import { useUploadQueue } from '../services/uploadApi';
|
import { useUploadQueue } from '../services/uploadApi';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||||
import { fetchTasks, type TaskItem } from '../services/tasksApi';
|
import { fetchTasks, type TaskItem } from '../services/tasksApi';
|
||||||
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
|
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
|
||||||
@@ -182,8 +182,7 @@ export default function HomeScreen() {
|
|||||||
const [taskError, setTaskError] = React.useState<string | null>(null);
|
const [taskError, setTaskError] = React.useState<string | null>(null);
|
||||||
const [hasSwiped, setHasSwiped] = React.useState(false);
|
const [hasSwiped, setHasSwiped] = React.useState(false);
|
||||||
const [emotionMap, setEmotionMap] = React.useState<Record<string, string>>({});
|
const [emotionMap, setEmotionMap] = React.useState<Record<string, string>>({});
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const bentoSurface = getBentoSurfaceTokens(isDark);
|
const bentoSurface = getBentoSurfaceTokens(isDark);
|
||||||
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.75)';
|
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.75)';
|
||||||
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.08)';
|
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.08)';
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { QrCode, ArrowRight } from 'lucide-react';
|
|||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { fetchEvent } from '../services/eventApi';
|
import { fetchEvent } from '../services/eventApi';
|
||||||
import { readGuestName } from '../context/GuestIdentityContext';
|
import { readGuestName } from '../context/GuestIdentityContext';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import EventLogo from '../components/EventLogo';
|
||||||
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
const qrConfig = { fps: 10, qrbox: { width: 240, height: 240 } } as const;
|
const qrConfig = { fps: 10, qrbox: { width: 240, height: 240 } } as const;
|
||||||
|
|
||||||
@@ -24,14 +25,14 @@ export default function LandingScreen() {
|
|||||||
const [errorKey, setErrorKey] = React.useState<LandingErrorKey | null>(null);
|
const [errorKey, setErrorKey] = React.useState<LandingErrorKey | null>(null);
|
||||||
const [isScanning, setIsScanning] = React.useState(false);
|
const [isScanning, setIsScanning] = React.useState(false);
|
||||||
const scannerRef = React.useRef<Html5Qrcode | null>(null);
|
const scannerRef = React.useRef<Html5Qrcode | null>(null);
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.7)' : 'rgba(255, 255, 255, 0.9)';
|
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.7)' : 'rgba(255, 255, 255, 0.9)';
|
||||||
const cardBorder = isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
const cardBorder = isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
||||||
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
|
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
|
||||||
const mutedText = isDark ? 'rgba(226, 232, 240, 0.78)' : 'rgba(15, 23, 42, 0.6)';
|
const mutedText = isDark ? 'rgba(226, 232, 240, 0.78)' : 'rgba(15, 23, 42, 0.6)';
|
||||||
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.1)' : 'rgba(15, 23, 42, 0.06)';
|
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.1)' : 'rgba(15, 23, 42, 0.06)';
|
||||||
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
||||||
|
const brandName = 'Fotospiel';
|
||||||
|
|
||||||
const errorMessage = errorKey ? t(`landing.errors.${errorKey}`) : null;
|
const errorMessage = errorKey ? t(`landing.errors.${errorKey}`) : null;
|
||||||
|
|
||||||
@@ -159,6 +160,12 @@ export default function LandingScreen() {
|
|||||||
return (
|
return (
|
||||||
<YStack flex={1} minHeight="100vh" padding="$5" justifyContent="center" backgroundColor={isDark ? '#0B101E' : '#FFF8F5'}>
|
<YStack flex={1} minHeight="100vh" padding="$5" justifyContent="center" backgroundColor={isDark ? '#0B101E' : '#FFF8F5'}>
|
||||||
<YStack gap="$5" maxWidth={480} width="100%" alignSelf="center">
|
<YStack gap="$5" maxWidth={480} width="100%" alignSelf="center">
|
||||||
|
<XStack alignItems="center" gap="$3">
|
||||||
|
<EventLogo name={brandName} size="s" />
|
||||||
|
<Text fontSize="$5" fontFamily="$display" fontWeight="$8" color={primaryText}>
|
||||||
|
{brandName}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
<YStack gap="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$8" fontFamily="$display" fontWeight="$9" color={primaryText}>
|
<Text fontSize="$8" fontFamily="$display" fontWeight="$9" color={primaryText}>
|
||||||
{t('landing.pageTitle')}
|
{t('landing.pageTitle')}
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { YStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Card } from '@tamagui/card';
|
import { Card } from '@tamagui/card';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||||
import { LegalMarkdown } from '@/guest/components/legal-markdown';
|
import { LegalMarkdown } from '@/guest/components/legal-markdown';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import EventLogo from '../components/EventLogo';
|
||||||
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
export default function LegalScreen() {
|
export default function LegalScreen() {
|
||||||
const { page } = useParams<{ page: string }>();
|
const { page } = useParams<{ page: string }>();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
|
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
|
||||||
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
|
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
|
||||||
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.75)' : 'rgba(255, 255, 255, 0.9)';
|
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.75)' : 'rgba(255, 255, 255, 0.9)';
|
||||||
const cardBorder = isDark ? 'rgba(148, 163, 184, 0.18)' : 'rgba(15, 23, 42, 0.12)';
|
const cardBorder = isDark ? 'rgba(148, 163, 184, 0.18)' : 'rgba(15, 23, 42, 0.12)';
|
||||||
|
const brandName = 'Fotospiel';
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [title, setTitle] = React.useState('');
|
const [title, setTitle] = React.useState('');
|
||||||
const [body, setBody] = React.useState('');
|
const [body, setBody] = React.useState('');
|
||||||
@@ -67,6 +68,12 @@ export default function LegalScreen() {
|
|||||||
return (
|
return (
|
||||||
<YStack flex={1} minHeight="100vh" padding="$5" backgroundColor={isDark ? '#0B101E' : '#FFF8F5'}>
|
<YStack flex={1} minHeight="100vh" padding="$5" backgroundColor={isDark ? '#0B101E' : '#FFF8F5'}>
|
||||||
<YStack gap="$4" maxWidth={720} width="100%" alignSelf="center">
|
<YStack gap="$4" maxWidth={720} width="100%" alignSelf="center">
|
||||||
|
<XStack alignItems="center" gap="$3">
|
||||||
|
<EventLogo name={brandName} size="s" />
|
||||||
|
<Text fontSize="$5" fontFamily="$display" fontWeight="$8" color={primaryText}>
|
||||||
|
{brandName}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
<Text fontSize="$7" fontFamily="$display" fontWeight="$9" color={primaryText}>
|
<Text fontSize="$7" fontFamily="$display" fontWeight="$9" color={primaryText}>
|
||||||
{title || fallbackTitle}
|
{title || fallbackTitle}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import LiveShowBackdrop from '@/guest/components/LiveShowBackdrop';
|
|||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { prefersReducedMotion } from '@/guest/lib/motion';
|
import { prefersReducedMotion } from '@/guest/lib/motion';
|
||||||
import { resolveLiveShowEffect } from '@/guest/lib/liveShowEffects';
|
import { resolveLiveShowEffect } from '@/guest/lib/liveShowEffects';
|
||||||
|
import EventLogo from '../components/EventLogo';
|
||||||
|
|
||||||
export default function LiveShowScreen() {
|
export default function LiveShowScreen() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
@@ -125,9 +126,12 @@ export default function LiveShowScreen() {
|
|||||||
>
|
>
|
||||||
<LiveShowBackdrop mode={settings.background_mode} photo={frame[0]} intensity={settings.effect_intensity} />
|
<LiveShowBackdrop mode={settings.background_mode} photo={frame[0]} intensity={settings.effect_intensity} />
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between px-6 py-4 text-sm">
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between px-6 py-4 text-sm">
|
||||||
<span className="font-semibold text-white" style={{ fontFamily: 'var(--guest-heading-font)' }}>
|
<div className="flex items-center gap-2">
|
||||||
{stageTitle}
|
<EventLogo name={stageTitle} icon={event?.type?.icon ?? null} size="s" />
|
||||||
</span>
|
<span className="font-semibold text-white" style={{ fontFamily: 'var(--guest-heading-font)' }}>
|
||||||
|
{stageTitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
|
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
|
||||||
{connection === 'sse'
|
{connection === 'sse'
|
||||||
? t('liveShowPlayer.connection.live', 'Live')
|
? t('liveShowPlayer.connection.live', 'Live')
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { useGesture } from '@use-gesture/react';
|
|||||||
import { animated, to, useSpring } from '@react-spring/web';
|
import { animated, to, useSpring } from '@react-spring/web';
|
||||||
import AppShell from '../components/AppShell';
|
import AppShell from '../components/AppShell';
|
||||||
import SurfaceCard from '../components/SurfaceCard';
|
import SurfaceCard from '../components/SurfaceCard';
|
||||||
|
import ShareSheet from '../components/ShareSheet';
|
||||||
import { useEventData } from '../context/EventDataContext';
|
import { useEventData } from '../context/EventDataContext';
|
||||||
import { fetchGallery, fetchPhoto, likePhoto, createPhotoShareLink } from '../services/photosApi';
|
import { fetchGallery, fetchPhoto, likePhoto, createPhotoShareLink } from '../services/photosApi';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
import { buildEventPath } from '../lib/routes';
|
import { buildEventPath } from '../lib/routes';
|
||||||
|
import { pushGuestToast } from '../lib/toast';
|
||||||
|
|
||||||
type LightboxPhoto = {
|
type LightboxPhoto = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -62,13 +64,12 @@ function mapPhoto(photo: Record<string, unknown>): LightboxPhoto | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PhotoLightboxScreen() {
|
export default function PhotoLightboxScreen() {
|
||||||
const { token } = useEventData();
|
const { token, event } = useEventData();
|
||||||
const { photoId } = useParams<{ photoId: string }>();
|
const { photoId } = useParams<{ photoId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||||
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
||||||
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
||||||
@@ -80,7 +81,10 @@ export default function PhotoLightboxScreen() {
|
|||||||
const [loadingMore, setLoadingMore] = React.useState(false);
|
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [likes, setLikes] = React.useState<Record<number, number>>({});
|
const [likes, setLikes] = React.useState<Record<number, number>>({});
|
||||||
const [shareStatus, setShareStatus] = React.useState<'idle' | 'loading' | 'copied' | 'failed'>('idle');
|
const [shareSheet, setShareSheet] = React.useState<{ url: string | null; loading: boolean }>({
|
||||||
|
url: null,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
const zoomContainerRef = React.useRef<HTMLDivElement | null>(null);
|
const zoomContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const zoomImageRef = React.useRef<HTMLImageElement | null>(null);
|
const zoomImageRef = React.useRef<HTMLImageElement | null>(null);
|
||||||
const baseSizeRef = React.useRef({ width: 0, height: 0 });
|
const baseSizeRef = React.useRef({ width: 0, height: 0 });
|
||||||
@@ -289,36 +293,82 @@ export default function PhotoLightboxScreen() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Like failed', error);
|
console.error('Like failed', error);
|
||||||
}
|
}
|
||||||
}, [selected, token]);
|
}, [selected, t, token]);
|
||||||
|
|
||||||
const handleShare = React.useCallback(async () => {
|
const shareTitle = event?.name ?? t('share.title', 'Shared photo');
|
||||||
|
const shareText = t('share.shareText', 'Check out this moment on Fotospiel.');
|
||||||
|
|
||||||
|
const openShareSheet = React.useCallback(async () => {
|
||||||
if (!selected || !token) return;
|
if (!selected || !token) return;
|
||||||
setShareStatus('loading');
|
setShareSheet({ url: null, loading: true });
|
||||||
try {
|
try {
|
||||||
const payload = await createPhotoShareLink(token, selected.id);
|
const payload = await createPhotoShareLink(token, selected.id);
|
||||||
const url = payload?.url ?? '';
|
const url = payload?.url ?? null;
|
||||||
if (!url) {
|
setShareSheet({ url, loading: false });
|
||||||
throw new Error('missing share url');
|
} catch (error) {
|
||||||
|
console.error('Share failed', error);
|
||||||
|
pushGuestToast({ text: t('share.error', 'Share failed'), type: 'error' });
|
||||||
|
setShareSheet({ url: null, loading: false });
|
||||||
|
}
|
||||||
|
}, [selected, token]);
|
||||||
|
|
||||||
|
const closeShareSheet = React.useCallback(() => {
|
||||||
|
setShareSheet({ url: null, loading: false });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shareWhatsApp = React.useCallback(
|
||||||
|
(url?: string | null) => {
|
||||||
|
if (!url) return;
|
||||||
|
const waUrl = `https://wa.me/?text=${encodeURIComponent(`${shareText} ${url}`)}`;
|
||||||
|
window.open(waUrl, '_blank', 'noopener');
|
||||||
|
closeShareSheet();
|
||||||
|
},
|
||||||
|
[closeShareSheet, shareText]
|
||||||
|
);
|
||||||
|
|
||||||
|
const shareMessages = React.useCallback(
|
||||||
|
(url?: string | null) => {
|
||||||
|
if (!url) return;
|
||||||
|
const smsUrl = `sms:?&body=${encodeURIComponent(`${shareText} ${url}`)}`;
|
||||||
|
window.open(smsUrl, '_blank', 'noopener');
|
||||||
|
closeShareSheet();
|
||||||
|
},
|
||||||
|
[closeShareSheet, shareText]
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyLink = React.useCallback(
|
||||||
|
async (url?: string | null) => {
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard?.writeText(url);
|
||||||
|
pushGuestToast({ text: t('share.copySuccess', 'Link copied!') });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Copy failed', error);
|
||||||
|
pushGuestToast({ text: t('share.copyError', 'Link could not be copied.'), type: 'error' });
|
||||||
|
} finally {
|
||||||
|
closeShareSheet();
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[closeShareSheet, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const shareNative = React.useCallback(
|
||||||
|
(url?: string | null) => {
|
||||||
|
if (!url) return;
|
||||||
const data: ShareData = {
|
const data: ShareData = {
|
||||||
title: t('share.defaultEvent', 'A special moment'),
|
title: shareTitle,
|
||||||
text: t('share.shareText', 'Check out this moment on Fotospiel.'),
|
text: shareText,
|
||||||
url,
|
url,
|
||||||
};
|
};
|
||||||
if (navigator.share && (!navigator.canShare || navigator.canShare(data))) {
|
if (navigator.share && (!navigator.canShare || navigator.canShare(data))) {
|
||||||
await navigator.share(data);
|
navigator.share(data).catch(() => undefined);
|
||||||
setShareStatus('idle');
|
closeShareSheet();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await navigator.clipboard?.writeText(url);
|
void copyLink(url);
|
||||||
setShareStatus('copied');
|
},
|
||||||
} catch (error) {
|
[closeShareSheet, copyLink, shareText, shareTitle]
|
||||||
console.error('Share failed', error);
|
);
|
||||||
setShareStatus('failed');
|
|
||||||
} finally {
|
|
||||||
window.setTimeout(() => setShareStatus('idle'), 2000);
|
|
||||||
}
|
|
||||||
}, [selected, t, token]);
|
|
||||||
|
|
||||||
const bind = useGesture(
|
const bind = useGesture(
|
||||||
{
|
{
|
||||||
@@ -557,20 +607,14 @@ export default function PhotoLightboxScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
unstyled
|
unstyled
|
||||||
onPress={handleShare}
|
onPress={openShareSheet}
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
paddingVertical="$2"
|
paddingVertical="$2"
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" gap="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Share2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
<Share2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||||
<Text fontSize="$2" fontWeight="$6">
|
<Text fontSize="$2" fontWeight="$6">
|
||||||
{shareStatus === 'loading'
|
{shareSheet.loading ? t('share.loading', 'Sharing...') : t('share.button', 'Share')}
|
||||||
? t('share.loading', 'Sharing...')
|
|
||||||
: shareStatus === 'copied'
|
|
||||||
? t('share.copySuccess', 'Copied')
|
|
||||||
: shareStatus === 'failed'
|
|
||||||
? t('share.copyError', 'Copy failed')
|
|
||||||
: t('share.button', 'Share')}
|
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -584,6 +628,22 @@ export default function PhotoLightboxScreen() {
|
|||||||
)}
|
)}
|
||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
<ShareSheet
|
||||||
|
open={shareSheet.loading || Boolean(shareSheet.url)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
closeShareSheet();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
photoId={selected?.id}
|
||||||
|
eventName={event?.name ?? null}
|
||||||
|
url={shareSheet.url}
|
||||||
|
loading={shareSheet.loading}
|
||||||
|
onShareNative={() => shareNative(shareSheet.url)}
|
||||||
|
onShareWhatsApp={() => shareWhatsApp(shareSheet.url)}
|
||||||
|
onShareMessages={() => shareMessages(shareSheet.url)}
|
||||||
|
onCopyLink={() => copyLink(shareSheet.url)}
|
||||||
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { YStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Button } from '@tamagui/button';
|
import { Button } from '@tamagui/button';
|
||||||
import { Input } from '@tamagui/input';
|
import { Input } from '@tamagui/input';
|
||||||
@@ -8,7 +8,8 @@ import { Card } from '@tamagui/card';
|
|||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useEventData } from '../context/EventDataContext';
|
import { useEventData } from '../context/EventDataContext';
|
||||||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import EventLogo from '../components/EventLogo';
|
||||||
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
export default function ProfileSetupScreen() {
|
export default function ProfileSetupScreen() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
@@ -16,8 +17,7 @@ export default function ProfileSetupScreen() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { event, status } = useEventData();
|
const { event, status } = useEventData();
|
||||||
const identity = useGuestIdentity();
|
const identity = useGuestIdentity();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
|
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
|
||||||
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
|
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
|
||||||
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.75)' : 'rgba(255, 255, 255, 0.9)';
|
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.75)' : 'rgba(255, 255, 255, 0.9)';
|
||||||
@@ -67,9 +67,12 @@ export default function ProfileSetupScreen() {
|
|||||||
<YStack flex={1} minHeight="100vh" padding="$5" justifyContent="center" backgroundColor={isDark ? '#0B101E' : '#FFF8F5'}>
|
<YStack flex={1} minHeight="100vh" padding="$5" justifyContent="center" backgroundColor={isDark ? '#0B101E' : '#FFF8F5'}>
|
||||||
<YStack gap="$5" maxWidth={420} width="100%" alignSelf="center">
|
<YStack gap="$5" maxWidth={420} width="100%" alignSelf="center">
|
||||||
<YStack gap="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$7" fontFamily="$display" fontWeight="$9" color={primaryText}>
|
<XStack alignItems="center" gap="$3">
|
||||||
{event.name}
|
<EventLogo name={event.name} icon={event.type?.icon ?? null} size="m" />
|
||||||
</Text>
|
<Text fontSize="$7" fontFamily="$display" fontWeight="$9" color={primaryText}>
|
||||||
|
{event.name}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
<Text color={mutedText}>
|
<Text color={mutedText}>
|
||||||
{t('profileSetup.card.description')}
|
{t('profileSetup.card.description')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ import { useParams } from 'react-router-dom';
|
|||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Button } from '@tamagui/button';
|
import { Button } from '@tamagui/button';
|
||||||
import { Image as ImageIcon, Download, Share2 } from 'lucide-react';
|
import { Download, Share2 } from 'lucide-react';
|
||||||
import { Sheet } from '@tamagui/sheet';
|
import { Sheet } from '@tamagui/sheet';
|
||||||
import StandaloneShell from '../components/StandaloneShell';
|
import StandaloneShell from '../components/StandaloneShell';
|
||||||
import SurfaceCard from '../components/SurfaceCard';
|
import SurfaceCard from '../components/SurfaceCard';
|
||||||
|
import EventLogo from '../components/EventLogo';
|
||||||
import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '@/guest/services/galleryApi';
|
import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '@/guest/services/galleryApi';
|
||||||
import { createPhotoShareLink } from '@/guest/services/photosApi';
|
import { createPhotoShareLink } from '@/guest/services/photosApi';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { EventBrandingProvider } from '@/guest/context/EventBrandingContext';
|
import { EventBrandingProvider } from '@/guest/context/EventBrandingContext';
|
||||||
import { mapEventBranding } from '../lib/eventBranding';
|
import { mapEventBranding } from '../lib/eventBranding';
|
||||||
import { BrandingTheme } from '../lib/brandingTheme';
|
import { BrandingTheme } from '../lib/brandingTheme';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
type GalleryState = {
|
type GalleryState = {
|
||||||
meta: GalleryMetaResponse | null;
|
meta: GalleryMetaResponse | null;
|
||||||
@@ -40,9 +41,6 @@ const PAGE_SIZE = 30;
|
|||||||
export default function PublicGalleryScreen() {
|
export default function PublicGalleryScreen() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const { t, locale } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
const { resolved } = useAppearance();
|
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
|
||||||
const [state, setState] = React.useState<GalleryState>(INITIAL_STATE);
|
const [state, setState] = React.useState<GalleryState>(INITIAL_STATE);
|
||||||
const [selected, setSelected] = React.useState<GalleryPhotoResource | null>(null);
|
const [selected, setSelected] = React.useState<GalleryPhotoResource | null>(null);
|
||||||
const [shareLoading, setShareLoading] = React.useState(false);
|
const [shareLoading, setShareLoading] = React.useState(false);
|
||||||
@@ -50,16 +48,10 @@ export default function PublicGalleryScreen() {
|
|||||||
|
|
||||||
const branding = React.useMemo(() => {
|
const branding = React.useMemo(() => {
|
||||||
if (!state.meta) return null;
|
if (!state.meta) return null;
|
||||||
const raw = state.meta.branding ?? null;
|
return mapEventBranding(state.meta.branding ?? null);
|
||||||
return mapEventBranding({
|
|
||||||
primary_color: raw.primary_color,
|
|
||||||
secondary_color: raw.secondary_color,
|
|
||||||
background_color: raw.background_color,
|
|
||||||
surface_color: raw.surface_color ?? raw.background_color,
|
|
||||||
palette: raw.palette ?? undefined,
|
|
||||||
mode: raw.mode ?? 'auto',
|
|
||||||
} as any);
|
|
||||||
}, [state.meta]);
|
}, [state.meta]);
|
||||||
|
const { isDark } = useGuestThemeVariant(branding);
|
||||||
|
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
||||||
|
|
||||||
const loadInitial = React.useCallback(async () => {
|
const loadInitial = React.useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -117,15 +109,17 @@ export default function PublicGalleryScreen() {
|
|||||||
const content = (
|
const content = (
|
||||||
<StandaloneShell>
|
<StandaloneShell>
|
||||||
<SurfaceCard glow>
|
<SurfaceCard glow>
|
||||||
<XStack alignItems="center" gap="$2">
|
<XStack alignItems="center" gap="$3">
|
||||||
<ImageIcon size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
<EventLogo name={state.meta?.event?.name ?? t('galleryPage.hero.eventFallback', 'Event')} size="s" />
|
||||||
<Text fontSize="$4" fontWeight="$7">
|
<YStack gap="$1">
|
||||||
{t('galleryPublic.title')}
|
<Text fontSize="$4" fontWeight="$7">
|
||||||
</Text>
|
{t('galleryPublic.title')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$2" color={mutedText}>
|
||||||
|
{state.meta?.event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
<Text fontSize="$2" color={mutedText} marginTop="$2">
|
|
||||||
{state.meta?.event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
|
|
||||||
</Text>
|
|
||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
|
|
||||||
{state.loading ? (
|
{state.loading ? (
|
||||||
|
|||||||
@@ -6,21 +6,22 @@ import { Share2, QrCode, Link, Users } from 'lucide-react';
|
|||||||
import AppShell from '../components/AppShell';
|
import AppShell from '../components/AppShell';
|
||||||
import { useEventData } from '../context/EventDataContext';
|
import { useEventData } from '../context/EventDataContext';
|
||||||
import { buildEventShareLink } from '../services/eventLink';
|
import { buildEventShareLink } from '../services/eventLink';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { usePollStats } from '../hooks/usePollStats';
|
||||||
|
import { fetchEventQrCode } from '../services/qrApi';
|
||||||
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
|
|
||||||
export default function ShareScreen() {
|
export default function ShareScreen() {
|
||||||
const { event, token } = useEventData();
|
const { event, token } = useEventData();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||||
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
|
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
|
||||||
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'failed'>('idle');
|
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'failed'>('idle');
|
||||||
|
const { stats } = usePollStats(token ?? null);
|
||||||
|
const [qrCodeDataUrl, setQrCodeDataUrl] = React.useState('');
|
||||||
|
const [qrLoading, setQrLoading] = React.useState(false);
|
||||||
const shareUrl = buildEventShareLink(event, token);
|
const shareUrl = buildEventShareLink(event, token);
|
||||||
const qrUrl = shareUrl
|
|
||||||
? `https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent(shareUrl)}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const handleCopy = React.useCallback(async () => {
|
const handleCopy = React.useCallback(async () => {
|
||||||
if (!shareUrl) {
|
if (!shareUrl) {
|
||||||
@@ -52,6 +53,44 @@ export default function ShareScreen() {
|
|||||||
}
|
}
|
||||||
}, [event?.name, handleCopy, shareUrl]);
|
}, [event?.name, handleCopy, shareUrl]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setQrCodeDataUrl('');
|
||||||
|
setQrLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
setQrLoading(true);
|
||||||
|
|
||||||
|
fetchEventQrCode(token, 240)
|
||||||
|
.then((payload) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQrCodeDataUrl(payload.qr_code_data_url ?? '');
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Failed to load QR code', error);
|
||||||
|
setQrCodeDataUrl('');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) {
|
||||||
|
setQrLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const guestCountLabel = stats.onlineGuests.toString();
|
||||||
|
const inviteDisabled = !shareUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<YStack gap="$4">
|
<YStack gap="$4">
|
||||||
@@ -94,14 +133,14 @@ export default function ShareScreen() {
|
|||||||
: 'radial-gradient(circle at 30% 30%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 18%, white), transparent 60%)',
|
: 'radial-gradient(circle at 30% 30%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 18%, white), transparent 60%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{qrUrl ? (
|
{qrCodeDataUrl ? (
|
||||||
<img
|
<img
|
||||||
src={qrUrl}
|
src={qrCodeDataUrl}
|
||||||
alt={t('share.invite.qrAlt', 'Event QR code')}
|
alt={t('share.invite.qrAlt', 'Event QR code')}
|
||||||
style={{ width: 120, height: 120, borderRadius: 16 }}
|
style={{ width: 120, height: 120, borderRadius: 16 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<QrCode size={28} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
<QrCode size={qrLoading ? 22 : 28} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||||
)}
|
)}
|
||||||
<Text fontSize="$3" fontWeight="$7">
|
<Text fontSize="$3" fontWeight="$7">
|
||||||
{t('share.invite.qrLabel', 'Show QR')}
|
{t('share.invite.qrLabel', 'Show QR')}
|
||||||
@@ -155,12 +194,27 @@ export default function ShareScreen() {
|
|||||||
{t('share.invite.guestsTitle', 'Guests joined')}
|
{t('share.invite.guestsTitle', 'Guests joined')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
<YStack gap="$1">
|
||||||
|
<Text fontSize="$6" fontWeight="$8">
|
||||||
|
{guestCountLabel}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||||
|
{t('home.stats.online', 'Guests online')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||||
{event?.name
|
{event?.name
|
||||||
? t('share.invite.guestsSubtitleEvent', 'Share {event} with your guests.', { event: event.name })
|
? t('share.invite.guestsSubtitleEvent', 'Share {event} with your guests.', { event: event.name })
|
||||||
: t('share.invite.guestsSubtitle', 'Share the event with your guests.')}
|
: t('share.invite.guestsSubtitle', 'Share the event with your guests.')}
|
||||||
</Text>
|
</Text>
|
||||||
<Button size="$3" backgroundColor="$primary" borderRadius="$pill" alignSelf="flex-start" onPress={handleShare}>
|
<Button
|
||||||
|
size="$3"
|
||||||
|
backgroundColor="$primary"
|
||||||
|
borderRadius="$pill"
|
||||||
|
alignSelf="flex-start"
|
||||||
|
onPress={handleShare}
|
||||||
|
disabled={inviteDisabled}
|
||||||
|
>
|
||||||
{t('share.invite.send', 'Send invite')}
|
{t('share.invite.send', 'Send invite')}
|
||||||
</Button>
|
</Button>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ import { Button } from '@tamagui/button';
|
|||||||
import { AlertCircle, Download } from 'lucide-react';
|
import { AlertCircle, Download } from 'lucide-react';
|
||||||
import StandaloneShell from '../components/StandaloneShell';
|
import StandaloneShell from '../components/StandaloneShell';
|
||||||
import SurfaceCard from '../components/SurfaceCard';
|
import SurfaceCard from '../components/SurfaceCard';
|
||||||
|
import EventLogo from '../components/EventLogo';
|
||||||
import { fetchPhotoShare } from '@/guest/services/photosApi';
|
import { fetchPhotoShare } from '@/guest/services/photosApi';
|
||||||
|
import type { EventBrandingPayload } from '@/guest/services/eventApi';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { EventBrandingProvider } from '@/guest/context/EventBrandingContext';
|
||||||
|
import { mapEventBranding } from '../lib/eventBranding';
|
||||||
|
import { BrandingTheme } from '../lib/brandingTheme';
|
||||||
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
interface ShareResponse {
|
interface ShareResponse {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -22,19 +27,25 @@ interface ShareResponse {
|
|||||||
image_urls: { full: string; thumbnail: string };
|
image_urls: { full: string; thumbnail: string };
|
||||||
};
|
};
|
||||||
event?: { id: number; name?: string | null } | null;
|
event?: { id: number; name?: string | null } | null;
|
||||||
|
branding?: EventBrandingPayload | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SharedPhotoScreen() {
|
export default function SharedPhotoScreen() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { resolved } = useAppearance();
|
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
|
||||||
const [state, setState] = React.useState<{ loading: boolean; error: string | null; data: ShareResponse | null }>({
|
const [state, setState] = React.useState<{ loading: boolean; error: string | null; data: ShareResponse | null }>({
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
data: null,
|
data: null,
|
||||||
});
|
});
|
||||||
|
const branding = React.useMemo(() => {
|
||||||
|
if (!state.data?.branding) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return mapEventBranding(state.data.branding);
|
||||||
|
}, [state.data]);
|
||||||
|
const { isDark } = useGuestThemeVariant(branding);
|
||||||
|
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -93,15 +104,20 @@ export default function SharedPhotoScreen() {
|
|||||||
const { data } = state;
|
const { data } = state;
|
||||||
const chips = buildChips(data, t);
|
const chips = buildChips(data, t);
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<StandaloneShell>
|
<StandaloneShell>
|
||||||
<SurfaceCard glow>
|
<SurfaceCard glow>
|
||||||
<Text fontSize="$2" letterSpacing={2} textTransform="uppercase" color={mutedText}>
|
<XStack alignItems="center" gap="$3">
|
||||||
{t('share.title', 'Geteiltes Foto')}
|
<EventLogo name={data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')} size="s" />
|
||||||
</Text>
|
<YStack gap="$1">
|
||||||
<Text fontSize="$6" fontWeight="$8" marginTop="$2">
|
<Text fontSize="$2" letterSpacing={2} textTransform="uppercase" color={mutedText}>
|
||||||
{data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')}
|
{t('share.title', 'Geteiltes Foto')}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text fontSize="$6" fontWeight="$8">
|
||||||
|
{data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
</XStack>
|
||||||
{data.photo.title ? (
|
{data.photo.title ? (
|
||||||
<Text fontSize="$3" color={mutedText} marginTop="$1">
|
<Text fontSize="$3" color={mutedText} marginTop="$1">
|
||||||
{data.photo.title}
|
{data.photo.title}
|
||||||
@@ -151,6 +167,18 @@ export default function SharedPhotoScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
</StandaloneShell>
|
</StandaloneShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!branding) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EventBrandingProvider branding={branding}>
|
||||||
|
<BrandingTheme>
|
||||||
|
{content}
|
||||||
|
</BrandingTheme>
|
||||||
|
</EventBrandingProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChips(
|
function buildChips(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { ChevronLeft, ChevronRight, Pause, Play, Maximize2, Minimize2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Pause, Play, Maximize2, Minimize2 } from 'lucide-react';
|
||||||
import { useEventData } from '../context/EventDataContext';
|
import { useEventData } from '../context/EventDataContext';
|
||||||
|
import EventLogo from '../components/EventLogo';
|
||||||
import { fetchGallery, type GalleryPhoto } from '../services/photosApi';
|
import { fetchGallery, type GalleryPhoto } from '../services/photosApi';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
|
|
||||||
@@ -91,9 +92,12 @@ export default function SlideshowScreen() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-black text-white">
|
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-black text-white">
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between px-6 py-4 text-sm">
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between px-6 py-4 text-sm">
|
||||||
<span className="font-semibold text-white" style={{ fontFamily: 'var(--guest-heading-font)' }}>
|
<div className="flex items-center gap-2">
|
||||||
{event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
|
<EventLogo name={event?.name ?? t('galleryPage.hero.eventFallback', 'Event')} icon={event?.type?.icon ?? null} size="s" />
|
||||||
</span>
|
<span className="font-semibold text-white" style={{ fontFamily: 'var(--guest-heading-font)' }}>
|
||||||
|
{event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
|
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
|
||||||
{t('galleryPage.title', 'Gallery')}
|
{t('galleryPage.title', 'Gallery')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { fetchTasks, type TaskItem } from '../services/tasksApi';
|
|||||||
import { useEventData } from '../context/EventDataContext';
|
import { useEventData } from '../context/EventDataContext';
|
||||||
import { buildEventPath } from '../lib/routes';
|
import { buildEventPath } from '../lib/routes';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
|
|
||||||
function getTaskValue(task: TaskItem, key: string): string | undefined {
|
function getTaskValue(task: TaskItem, key: string): string | undefined {
|
||||||
const value = task?.[key as keyof TaskItem];
|
const value = task?.[key as keyof TaskItem];
|
||||||
@@ -39,8 +39,7 @@ export default function TaskDetailScreen() {
|
|||||||
const { taskId } = useParams<{ taskId: string }>();
|
const { taskId } = useParams<{ taskId: string }>();
|
||||||
const { t, locale } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
||||||
const [task, setTask] = React.useState<TaskItem | null>(null);
|
const [task, setTask] = React.useState<TaskItem | null>(null);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useTranslation } from '@/guest/i18n/useTranslation';
|
|||||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||||
import { fetchTasks } from '../services/tasksApi';
|
import { fetchTasks } from '../services/tasksApi';
|
||||||
import { fetchEmotions } from '../services/emotionsApi';
|
import { fetchEmotions } from '../services/emotionsApi';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
|
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
|
||||||
|
|
||||||
@@ -27,8 +27,7 @@ export default function TasksScreen() {
|
|||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { completedCount } = useGuestTaskProgress(token ?? undefined);
|
const { completedCount } = useGuestTaskProgress(token ?? undefined);
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||||
const cardShadow = isDark ? '0 16px 32px rgba(2, 6, 23, 0.35)' : '0 14px 24px rgba(15, 23, 42, 0.12)';
|
const cardShadow = isDark ? '0 16px 32px rgba(2, 6, 23, 0.35)' : '0 14px 24px rgba(15, 23, 42, 0.12)';
|
||||||
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import AppShell from '../components/AppShell';
|
|||||||
import SurfaceCard from '../components/SurfaceCard';
|
import SurfaceCard from '../components/SurfaceCard';
|
||||||
import { useUploadQueue } from '../services/uploadApi';
|
import { useUploadQueue } from '../services/uploadApi';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||||
import { useEventData } from '../context/EventDataContext';
|
import { useEventData } from '../context/EventDataContext';
|
||||||
import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi';
|
import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi';
|
||||||
@@ -20,8 +20,7 @@ export default function UploadQueueScreen() {
|
|||||||
const { token } = useEventData();
|
const { token } = useEventData();
|
||||||
const { items, loading, retryAll, clearFinished, refresh } = useUploadQueue();
|
const { items, loading, retryAll, clearFinished, refresh } = useUploadQueue();
|
||||||
const [progress, setProgress] = React.useState<ProgressMap>({});
|
const [progress, setProgress] = React.useState<ProgressMap>({});
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
|
||||||
const [pending, setPending] = React.useState<PendingUpload[]>([]);
|
const [pending, setPending] = React.useState<PendingUpload[]>([]);
|
||||||
const [pendingLoading, setPendingLoading] = React.useState(false);
|
const [pendingLoading, setPendingLoading] = React.useState(false);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import AppShell from '../components/AppShell';
|
|||||||
import { useEventData } from '../context/EventDataContext';
|
import { useEventData } from '../context/EventDataContext';
|
||||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||||
import { uploadPhoto, useUploadQueue } from '../services/uploadApi';
|
import { uploadPhoto, useUploadQueue } from '../services/uploadApi';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||||
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
|
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
|
||||||
@@ -15,6 +15,7 @@ import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services
|
|||||||
import { resolveUploadErrorDialog, type UploadErrorDialog } from '@/guest/lib/uploadErrorDialog';
|
import { resolveUploadErrorDialog, type UploadErrorDialog } from '@/guest/lib/uploadErrorDialog';
|
||||||
import { fetchTasks, type TaskItem } from '../services/tasksApi';
|
import { fetchTasks, type TaskItem } from '../services/tasksApi';
|
||||||
import SurfaceCard from '../components/SurfaceCard';
|
import SurfaceCard from '../components/SurfaceCard';
|
||||||
|
import { pushGuestToast } from '../lib/toast';
|
||||||
|
|
||||||
function getTaskValue(task: TaskItem, key: string): string | undefined {
|
function getTaskValue(task: TaskItem, key: string): string | undefined {
|
||||||
const value = task?.[key as keyof TaskItem];
|
const value = task?.[key as keyof TaskItem];
|
||||||
@@ -46,8 +47,7 @@ export default function UploadScreen() {
|
|||||||
const [mirror, setMirror] = React.useState(true);
|
const [mirror, setMirror] = React.useState(true);
|
||||||
const [previewFile, setPreviewFile] = React.useState<File | null>(null);
|
const [previewFile, setPreviewFile] = React.useState<File | null>(null);
|
||||||
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
|
||||||
const { resolved } = useAppearance();
|
const { isDark } = useGuestThemeVariant();
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||||
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
|
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
|
||||||
const iconColor = isDark ? '#F8FAFF' : '#0F172A';
|
const iconColor = isDark ? '#F8FAFF' : '#0F172A';
|
||||||
@@ -186,6 +186,7 @@ export default function UploadScreen() {
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
await enqueueFile(file);
|
await enqueueFile(file);
|
||||||
|
pushGuestToast({ text: t('uploadV2.toast.queued', 'Offline — added to upload queue.'), type: 'info' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +205,7 @@ export default function UploadScreen() {
|
|||||||
if (autoApprove) {
|
if (autoApprove) {
|
||||||
void triggerConfetti();
|
void triggerConfetti();
|
||||||
}
|
}
|
||||||
|
pushGuestToast({ text: t('uploadV2.toast.uploaded', 'Upload complete.'), type: 'success' });
|
||||||
void loadPending();
|
void loadPending();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const uploadErr = err as { code?: string; meta?: Record<string, unknown> };
|
const uploadErr = err as { code?: string; meta?: Record<string, unknown> };
|
||||||
|
|||||||
19
resources/js/guest-v2/services/qrApi.ts
Normal file
19
resources/js/guest-v2/services/qrApi.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { fetchJson } from './apiClient';
|
||||||
|
|
||||||
|
export type EventQrCodePayload = {
|
||||||
|
url?: string | null;
|
||||||
|
qr_code_data_url?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchEventQrCode(eventToken: string, size = 240): Promise<EventQrCodePayload> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (Number.isFinite(size)) {
|
||||||
|
params.set('size', String(size));
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/qr${query ? `?${query}` : ''}`;
|
||||||
|
|
||||||
|
const response = await fetchJson<EventQrCodePayload>(url, { noStore: true });
|
||||||
|
|
||||||
|
return response.data ?? { url: null, qr_code_data_url: null };
|
||||||
|
}
|
||||||
@@ -131,6 +131,10 @@ function resolveThemeVariant(
|
|||||||
? 'dark'
|
? 'dark'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (appearanceOverride) {
|
||||||
|
return appearanceOverride;
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === 'dark') {
|
if (mode === 'dark') {
|
||||||
return 'dark';
|
return 'dark';
|
||||||
}
|
}
|
||||||
@@ -139,10 +143,6 @@ function resolveThemeVariant(
|
|||||||
return 'light';
|
return 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appearanceOverride) {
|
|
||||||
return appearanceOverride;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (backgroundPrefers) {
|
if (backgroundPrefers) {
|
||||||
return backgroundPrefers;
|
return backgroundPrefers;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,4 +70,27 @@ describe('EventBrandingProvider', () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers explicit appearance over branding mode', async () => {
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
const darkBranding: EventBranding = {
|
||||||
|
...sampleBranding,
|
||||||
|
mode: 'dark',
|
||||||
|
backgroundColor: '#0f172a',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<AppearanceProvider>
|
||||||
|
<EventBrandingProvider branding={darkBranding}>
|
||||||
|
<div>Guest</div>
|
||||||
|
</EventBrandingProvider>
|
||||||
|
</AppearanceProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -561,6 +561,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
flash: 'Blitz',
|
flash: 'Blitz',
|
||||||
upload: 'Upload',
|
upload: 'Upload',
|
||||||
},
|
},
|
||||||
|
toast: {
|
||||||
|
queued: 'Offline – in der Warteschlange gespeichert.',
|
||||||
|
uploaded: 'Upload abgeschlossen.',
|
||||||
|
},
|
||||||
queue: {
|
queue: {
|
||||||
summary: '{waiting} wartend, {sending} sendend',
|
summary: '{waiting} wartend, {sending} sendend',
|
||||||
uploading: 'Upload {name} · {progress}%',
|
uploading: 'Upload {name} · {progress}%',
|
||||||
@@ -1461,6 +1465,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
flash: 'Flash',
|
flash: 'Flash',
|
||||||
upload: 'Upload',
|
upload: 'Upload',
|
||||||
},
|
},
|
||||||
|
toast: {
|
||||||
|
queued: 'Offline — added to upload queue.',
|
||||||
|
uploaded: 'Upload complete.',
|
||||||
|
},
|
||||||
queue: {
|
queue: {
|
||||||
summary: '{waiting} waiting, {sending} sending',
|
summary: '{waiting} waiting, {sending} sending',
|
||||||
uploading: 'Uploading {name} · {progress}%',
|
uploading: 'Uploading {name} · {progress}%',
|
||||||
|
|||||||
33
resources/js/guest/services/__tests__/photosApi.test.ts
Normal file
33
resources/js/guest/services/__tests__/photosApi.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPhotoShareLink } from '../photosApi';
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
describe('photosApi', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
global.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
document.head.innerHTML = '<meta name="csrf-token" content="csrf-token-demo" />';
|
||||||
|
localStorage.setItem('device-id', 'device-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a share link with CSRF headers', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ slug: 'demo', url: 'http://example.com/share/demo' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = await createPhotoShareLink('token', 123);
|
||||||
|
|
||||||
|
expect(payload.url).toBe('http://example.com/share/demo');
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [, options] = fetchMock.mock.calls[0];
|
||||||
|
const headers = options?.headers as Record<string, string>;
|
||||||
|
expect(headers['X-CSRF-TOKEN']).toBe('csrf-token-demo');
|
||||||
|
expect(headers['X-XSRF-TOKEN']).toBe('csrf-token-demo');
|
||||||
|
expect(headers['X-Device-Id']).toBe('device-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,7 @@
|
|||||||
import type { LocaleCode } from '../i18n/messages';
|
import type { LocaleCode } from '../i18n/messages';
|
||||||
|
import type { EventBrandingPayload } from './eventApi';
|
||||||
|
|
||||||
export interface GalleryBranding {
|
export type GalleryBranding = EventBrandingPayload;
|
||||||
primary_color: string;
|
|
||||||
secondary_color: string;
|
|
||||||
background_color: string;
|
|
||||||
surface_color?: string;
|
|
||||||
mode?: 'light' | 'dark' | 'auto';
|
|
||||||
palette?: {
|
|
||||||
primary?: string | null;
|
|
||||||
secondary?: string | null;
|
|
||||||
background?: string | null;
|
|
||||||
surface?: string | null;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GalleryMetaResponse {
|
export interface GalleryMetaResponse {
|
||||||
event: {
|
event: {
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export async function uploadPhoto(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> {
|
export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> {
|
||||||
const headers = getCsrfHeaders();
|
const headers = buildCsrfHeaders();
|
||||||
|
|
||||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/share`, {
|
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/share`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
|
|
||||||
Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show');
|
Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show');
|
||||||
Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats');
|
Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats');
|
||||||
|
Route::get('/events/{token}/qr', [EventPublicController::class, 'qr'])->name('events.qr');
|
||||||
Route::get('/events/{token}/package', [EventPublicController::class, 'package'])->name('events.package');
|
Route::get('/events/{token}/package', [EventPublicController::class, 'package'])->name('events.package');
|
||||||
Route::get('/events/{token}/notifications', [EventPublicController::class, 'notifications'])->name('events.notifications');
|
Route::get('/events/{token}/notifications', [EventPublicController::class, 'notifications'])->name('events.notifications');
|
||||||
Route::post('/events/{token}/notifications/{notification}/read', [EventPublicController::class, 'markNotificationRead'])
|
Route::post('/events/{token}/notifications/{notification}/read', [EventPublicController::class, 'markNotificationRead'])
|
||||||
|
|||||||
35
tests/Feature/Api/Event/EventQrCodeTest.php
Normal file
35
tests/Feature/Api/Event/EventQrCodeTest.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\Event;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Services\EventJoinTokenService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EventQrCodeTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_guest_can_fetch_event_qr_code(): void
|
||||||
|
{
|
||||||
|
$event = Event::factory()->create([
|
||||||
|
'status' => 'published',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = app(EventJoinTokenService::class)->createToken($event);
|
||||||
|
|
||||||
|
$response = $this->getJson("/api/v1/events/{$token->token}/qr?size=240");
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'url',
|
||||||
|
'qr_code_data_url',
|
||||||
|
])
|
||||||
|
->assertJsonPath('url', url('/e/'.$token->token));
|
||||||
|
|
||||||
|
$dataUrl = $response->json('qr_code_data_url');
|
||||||
|
$this->assertIsString($dataUrl);
|
||||||
|
$this->assertStringStartsWith('data:image/png;base64,', $dataUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,5 +82,18 @@ class PhotoShareLinkTest extends TestCase
|
|||||||
$response->assertJsonPath('photo.id', $photo->id);
|
$response->assertJsonPath('photo.id', $photo->id);
|
||||||
$response->assertJsonPath('photo.image_urls.full', fn ($value) => str_contains($value, '/photo-shares/'));
|
$response->assertJsonPath('photo.image_urls.full', fn ($value) => str_contains($value, '/photo-shares/'));
|
||||||
$response->assertJsonPath('event.id', $event->id);
|
$response->assertJsonPath('event.id', $event->id);
|
||||||
|
$response->assertJsonStructure([
|
||||||
|
'branding' => [
|
||||||
|
'primary_color',
|
||||||
|
'secondary_color',
|
||||||
|
'background_color',
|
||||||
|
'logo_mode',
|
||||||
|
'logo_value',
|
||||||
|
'palette',
|
||||||
|
'typography',
|
||||||
|
'logo',
|
||||||
|
'buttons',
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user