upgrade to tamagui v2 and guest pwa overhaul
This commit is contained in:
1
resources/js/guest-v2/lib/brandingTheme.ts
Normal file
1
resources/js/guest-v2/lib/brandingTheme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './brandingTheme.tsx';
|
||||
66
resources/js/guest-v2/lib/brandingTheme.tsx
Normal file
66
resources/js/guest-v2/lib/brandingTheme.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Theme } from '@tamagui/core';
|
||||
import React from 'react';
|
||||
import type { Appearance } from '@/hooks/use-appearance';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useEventBranding } from '@/guest/context/EventBrandingContext';
|
||||
import { relativeLuminance } from '@/guest/lib/color';
|
||||
import type { EventBranding } from '@/guest/types/event-branding';
|
||||
|
||||
const LIGHT_LUMINANCE_THRESHOLD = 0.65;
|
||||
const DARK_LUMINANCE_THRESHOLD = 0.35;
|
||||
|
||||
type ThemeVariant = 'light' | 'dark';
|
||||
|
||||
function resolveThemeVariant(
|
||||
mode: EventBranding['mode'],
|
||||
backgroundColor: string,
|
||||
appearanceOverride: 'light' | 'dark' | null
|
||||
): ThemeVariant {
|
||||
const prefersDark =
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false;
|
||||
const backgroundLuminance = relativeLuminance(backgroundColor);
|
||||
const backgroundPrefers =
|
||||
backgroundLuminance >= LIGHT_LUMINANCE_THRESHOLD
|
||||
? 'light'
|
||||
: backgroundLuminance <= DARK_LUMINANCE_THRESHOLD
|
||||
? 'dark'
|
||||
: null;
|
||||
|
||||
if (mode === 'dark') {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
if (mode === 'light') {
|
||||
return 'light';
|
||||
}
|
||||
|
||||
if (appearanceOverride) {
|
||||
return appearanceOverride;
|
||||
}
|
||||
|
||||
if (backgroundPrefers) {
|
||||
return backgroundPrefers;
|
||||
}
|
||||
|
||||
return prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function resolveGuestThemeName(
|
||||
branding: EventBranding,
|
||||
appearance: Appearance
|
||||
): 'guestLight' | 'guestNight' {
|
||||
const appearanceOverride = appearance === 'light' || appearance === 'dark' ? appearance : null;
|
||||
const background = branding.backgroundColor || branding.palette?.background || '#ffffff';
|
||||
const variant = resolveThemeVariant(branding.mode ?? 'auto', background, appearanceOverride);
|
||||
return variant === 'dark' ? 'guestNight' : 'guestLight';
|
||||
}
|
||||
|
||||
export function BrandingTheme({ children }: { children: React.ReactNode }) {
|
||||
const { branding } = useEventBranding();
|
||||
const { appearance } = useAppearance();
|
||||
const themeName = resolveGuestThemeName(branding, appearance);
|
||||
|
||||
return <Theme name={themeName}>{children}</Theme>;
|
||||
}
|
||||
18
resources/js/guest-v2/lib/device.ts
Normal file
18
resources/js/guest-v2/lib/device.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export function getDeviceId(): string {
|
||||
const KEY = 'device-id';
|
||||
let id = localStorage.getItem(KEY);
|
||||
if (!id) {
|
||||
id = genId();
|
||||
localStorage.setItem(KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function genId() {
|
||||
// Simple UUID v4-ish generator
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (crypto.getRandomValues(new Uint8Array(1))[0] & 0xf) >> 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
74
resources/js/guest-v2/lib/eventBranding.ts
Normal file
74
resources/js/guest-v2/lib/eventBranding.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { EventBranding } from '@/guest/types/event-branding';
|
||||
import type { EventBrandingPayload } from '@/guest/services/eventApi';
|
||||
|
||||
export function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const palette = raw.palette ?? {};
|
||||
const typography = raw.typography ?? {};
|
||||
const buttons = raw.buttons ?? {};
|
||||
const logo = raw.logo ?? {};
|
||||
const primary = palette.primary ?? raw.primary_color ?? '';
|
||||
const secondary = palette.secondary ?? raw.secondary_color ?? '';
|
||||
const background = palette.background ?? raw.background_color ?? '';
|
||||
const surface = palette.surface ?? raw.surface_color ?? background;
|
||||
const headingFont = typography.heading ?? raw.heading_font ?? raw.font_family ?? null;
|
||||
const bodyFont = typography.body ?? raw.body_font ?? raw.font_family ?? null;
|
||||
const sizePreset =
|
||||
(typography.size as 's' | 'm' | 'l' | undefined)
|
||||
?? (raw.font_size as 's' | 'm' | 'l' | undefined)
|
||||
?? 'm';
|
||||
const logoMode = logo.mode ?? raw.logo_mode ?? (logo.value || raw.logo_url ? 'upload' : 'emoticon');
|
||||
const logoValue = logo.value ?? raw.logo_value ?? raw.logo_url ?? raw.icon ?? null;
|
||||
const logoPosition = logo.position ?? raw.logo_position ?? 'left';
|
||||
const logoSize = (logo.size as 's' | 'm' | 'l' | undefined) ?? (raw.logo_size as 's' | 'm' | 'l' | undefined) ?? 'm';
|
||||
const buttonStyle =
|
||||
(buttons.style as 'filled' | 'outline' | undefined)
|
||||
?? (raw.button_style as 'filled' | 'outline' | undefined)
|
||||
?? 'filled';
|
||||
const buttonRadius =
|
||||
typeof buttons.radius === 'number'
|
||||
? buttons.radius
|
||||
: typeof raw.button_radius === 'number'
|
||||
? raw.button_radius
|
||||
: 12;
|
||||
const buttonPrimary = buttons.primary ?? raw.button_primary_color ?? primary ?? '';
|
||||
const buttonSecondary = buttons.secondary ?? raw.button_secondary_color ?? secondary ?? '';
|
||||
const linkColor = buttons.link_color ?? raw.link_color ?? secondary ?? '';
|
||||
|
||||
return {
|
||||
primaryColor: primary ?? '',
|
||||
secondaryColor: secondary ?? '',
|
||||
backgroundColor: background ?? '',
|
||||
fontFamily: bodyFont,
|
||||
logoUrl: logoMode === 'upload' ? (logoValue ?? null) : null,
|
||||
palette: {
|
||||
primary: primary ?? '',
|
||||
secondary: secondary ?? '',
|
||||
background: background ?? '',
|
||||
surface: surface ?? background ?? '',
|
||||
},
|
||||
typography: {
|
||||
heading: headingFont,
|
||||
body: bodyFont,
|
||||
sizePreset,
|
||||
},
|
||||
logo: {
|
||||
mode: logoMode,
|
||||
value: logoValue,
|
||||
position: logoPosition,
|
||||
size: logoSize,
|
||||
},
|
||||
buttons: {
|
||||
style: buttonStyle,
|
||||
radius: buttonRadius,
|
||||
primary: buttonPrimary,
|
||||
secondary: buttonSecondary,
|
||||
linkColor,
|
||||
},
|
||||
mode: (raw.mode as 'light' | 'dark' | 'auto' | undefined) ?? 'auto',
|
||||
useDefaultBranding: raw.use_default_branding ?? undefined,
|
||||
};
|
||||
}
|
||||
13
resources/js/guest-v2/lib/routes.ts
Normal file
13
resources/js/guest-v2/lib/routes.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export function buildEventPath(token: string | null, path: string): string {
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
if (!token) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (normalized === '/') {
|
||||
return `/e/${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
return `/e/${encodeURIComponent(token)}${normalized}`;
|
||||
}
|
||||
39
resources/js/guest-v2/lib/usePulseAnimation.ts
Normal file
39
resources/js/guest-v2/lib/usePulseAnimation.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
|
||||
type UsePulseAnimationOptions = {
|
||||
intervalMs?: number;
|
||||
delayMs?: number;
|
||||
};
|
||||
|
||||
export function usePulseAnimation({ intervalMs = 2400, delayMs = 0 }: UsePulseAnimationOptions = {}) {
|
||||
const [active, setActive] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const start = () => {
|
||||
setActive((prev) => !prev);
|
||||
interval = setInterval(() => {
|
||||
setActive((prev) => !prev);
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
if (delayMs > 0) {
|
||||
timeout = setTimeout(start, delayMs);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
}, [delayMs, intervalMs]);
|
||||
|
||||
return active;
|
||||
}
|
||||
29
resources/js/guest-v2/lib/useStaggeredReveal.ts
Normal file
29
resources/js/guest-v2/lib/useStaggeredReveal.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
type UseStaggeredRevealOptions = {
|
||||
steps: number;
|
||||
intervalMs?: number;
|
||||
delayMs?: number;
|
||||
};
|
||||
|
||||
export function useStaggeredReveal({ steps, intervalMs = 140, delayMs = 80 }: UseStaggeredRevealOptions) {
|
||||
const [stage, setStage] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timers: Array<ReturnType<typeof setTimeout>> = [];
|
||||
|
||||
for (let index = 1; index <= steps; index += 1) {
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
setStage(index);
|
||||
}, delayMs + intervalMs * (index - 1))
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
};
|
||||
}, [delayMs, intervalMs, steps]);
|
||||
|
||||
return stage;
|
||||
}
|
||||
Reference in New Issue
Block a user