Fix guest demo UX and enforce guest limits

This commit is contained in:
Codex Agent
2026-01-21 21:35:40 +01:00
parent a01a7ec399
commit 80dd12bb92
28 changed files with 812 additions and 118 deletions

View File

@@ -17,25 +17,26 @@ import { MobileSheet } from './components/Sheet';
import toast from 'react-hot-toast';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm';
import { getContrastingTextColor } from '@/guest/lib/color';
import { DEFAULT_EVENT_BRANDING } from '@/guest/context/EventBrandingContext';
import { getContrastingTextColor, relativeLuminance } from '@/guest/lib/color';
const BRANDING_FORM_DEFAULTS = {
primary: ADMIN_COLORS.primary,
accent: ADMIN_COLORS.accent,
background: '#ffffff',
surface: '#ffffff',
mode: 'auto' as const,
buttonStyle: 'filled' as const,
buttonRadius: 12,
buttonPrimary: ADMIN_COLORS.primary,
buttonSecondary: ADMIN_COLORS.accent,
linkColor: ADMIN_COLORS.accent,
fontSize: 'm' as const,
logoMode: 'upload' as const,
logoPosition: 'left' as const,
logoSize: 'm' as const,
primary: DEFAULT_EVENT_BRANDING.primaryColor,
accent: DEFAULT_EVENT_BRANDING.secondaryColor,
background: DEFAULT_EVENT_BRANDING.backgroundColor,
surface: DEFAULT_EVENT_BRANDING.palette?.surface ?? DEFAULT_EVENT_BRANDING.backgroundColor,
mode: DEFAULT_EVENT_BRANDING.mode ?? 'auto',
buttonStyle: DEFAULT_EVENT_BRANDING.buttons?.style ?? 'filled',
buttonRadius: DEFAULT_EVENT_BRANDING.buttons?.radius ?? 12,
buttonPrimary: DEFAULT_EVENT_BRANDING.buttons?.primary ?? DEFAULT_EVENT_BRANDING.primaryColor,
buttonSecondary: DEFAULT_EVENT_BRANDING.buttons?.secondary ?? DEFAULT_EVENT_BRANDING.secondaryColor,
linkColor: DEFAULT_EVENT_BRANDING.buttons?.linkColor ?? DEFAULT_EVENT_BRANDING.secondaryColor,
fontSize: DEFAULT_EVENT_BRANDING.typography?.sizePreset ?? 'm',
logoMode: DEFAULT_EVENT_BRANDING.logo?.mode ?? 'emoticon',
logoPosition: DEFAULT_EVENT_BRANDING.logo?.position ?? 'left',
logoSize: DEFAULT_EVENT_BRANDING.logo?.size ?? 'm',
};
const BRANDING_FORM_BASE: BrandingFormValues = {
@@ -176,10 +177,22 @@ export default function MobileBrandingPage() {
}, [tenantBrandingLoaded]);
const previewForm = form.useDefaultBranding && tenantBranding ? tenantBranding : form;
const previewBackground = previewForm.background;
const previewSurfaceCandidate = previewForm.surface || previewBackground;
const backgroundLuminance = relativeLuminance(previewBackground);
const surfaceLuminance = relativeLuminance(previewSurfaceCandidate);
const previewSurface = Math.abs(surfaceLuminance - backgroundLuminance) < 0.06
? backgroundLuminance >= 0.6
? '#ffffff'
: '#0f172a'
: previewSurfaceCandidate;
const previewForeground = backgroundLuminance >= 0.6 ? '#1f2937' : '#f8fafc';
const previewMutedForeground = backgroundLuminance >= 0.6 ? '#6b7280' : '#cbd5e1';
const previewBorder = backgroundLuminance >= 0.6 ? '#e6d9d6' : '#334155';
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const previewHeadingFont = previewForm.headingFont || 'Archivo Black';
const previewBodyFont = previewForm.bodyFont || 'Manrope';
const previewSurfaceText = getContrastingTextColor(previewForm.surface, '#ffffff', '#0f172a');
const previewHeadingFont = previewForm.headingFont || DEFAULT_EVENT_BRANDING.typography?.heading || DEFAULT_EVENT_BRANDING.fontFamily;
const previewBodyFont = previewForm.bodyFont || DEFAULT_EVENT_BRANDING.typography?.body || DEFAULT_EVENT_BRANDING.fontFamily;
const previewSurfaceText = previewForeground;
const previewScale = FONT_SIZE_SCALE[previewForm.fontSize] ?? 1;
const previewButtonColor = previewForm.buttonPrimary || previewForm.primary;
const previewButtonText = getContrastingTextColor(previewButtonColor, '#ffffff', '#0f172a');
@@ -560,8 +573,8 @@ export default function MobileBrandingPage() {
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.branding.previewTitle', 'Guest App Preview')}
</Text>
<YStack borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={previewForm.background} padding="$3" space="$2" alignItems="center">
<YStack width="100%" borderRadius={12} backgroundColor={previewForm.surface} borderWidth={1} borderColor={border} overflow="hidden">
<YStack borderRadius={16} borderWidth={1} borderColor={previewBorder} backgroundColor={previewBackground} padding="$3" space="$2" alignItems="center">
<YStack width="100%" borderRadius={12} backgroundColor={previewSurface} borderWidth={1} borderColor={previewBorder} overflow="hidden">
<YStack
height={64}
style={{ background: `linear-gradient(135deg, ${previewForm.primary}, ${previewForm.accent})` }}
@@ -602,18 +615,18 @@ export default function MobileBrandingPage() {
{previewTitle}
</Text>
<Text
color={previewSurfaceText}
style={{ fontFamily: previewBodyFont, opacity: 0.7, fontSize: 13 * previewScale }}
color={previewMutedForeground}
style={{ fontFamily: previewBodyFont, fontSize: 13 * previewScale }}
>
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
</Text>
</YStack>
</XStack>
<XStack space="$2" marginTop="$1">
<ColorSwatch color={previewForm.primary} label={t('events.branding.primary', 'Primary')} />
<ColorSwatch color={previewForm.accent} label={t('events.branding.accent', 'Accent')} />
<ColorSwatch color={previewForm.background} label={t('events.branding.background', 'Background')} />
<ColorSwatch color={previewForm.surface} label={t('events.branding.surface', 'Surface')} />
<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>
<XStack marginTop="$2">
<div
@@ -1220,11 +1233,11 @@ function ColorField({
);
}
function ColorSwatch({ color, label }: { color: string; label: string }) {
function ColorSwatch({ color, label, borderColor }: { color: string; label: string; borderColor?: string }) {
const { border, muted } = useAdminTheme();
return (
<YStack alignItems="center" space="$1">
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor={border} backgroundColor={color} />
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor={borderColor ?? border} backgroundColor={color} />
<Text fontSize="$xs" color={muted}>
{label}
</Text>

View File

@@ -34,6 +34,24 @@ describe('classifyPackageChange', () => {
const candidate = { ...active, id: 4, max_photos: 200, gallery_days: 10, features: { advanced_analytics: false } } as any;
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
});
it('treats unlimited sharing as an upgrade over limited sharing', () => {
const starter = { ...active, id: 5, features: ['limited_sharing'], max_photos: 100 } as any;
const standard = { ...active, id: 6, features: ['unlimited_sharing'], max_photos: 100 } as any;
expect(classifyPackageChange(standard, starter)).toEqual({ isUpgrade: true, isDowngrade: false });
});
it('treats watermark removal as an upgrade over base watermark', () => {
const base = { ...active, id: 7, watermark_allowed: false, features: [] } as any;
const removal = { ...active, id: 8, watermark_allowed: true, features: ['no_watermark'] } as any;
expect(classifyPackageChange(removal, base)).toEqual({ isUpgrade: true, isDowngrade: false });
});
it('keeps watermark custom as higher than watermark removal', () => {
const custom = { ...active, id: 9, watermark_allowed: true, features: [] } as any;
const removal = { ...active, id: 10, watermark_allowed: true, features: ['no_watermark'] } as any;
expect(classifyPackageChange(removal, custom)).toEqual({ isUpgrade: false, isDowngrade: true });
});
});
describe('selectRecommendedPackageId', () => {

View File

@@ -63,6 +63,20 @@ function collectFeatures(pkg: Package | null): Set<string> {
return new Set(getEnabledPackageFeatures(pkg));
}
const FEATURE_COMPATIBILITY: Record<string, string[]> = {
limited_sharing: ['limited_sharing', 'unlimited_sharing'],
watermark_base: ['watermark_base', 'watermark_custom', 'no_watermark'],
};
function featureSatisfied(feature: string, candidateFeatures: Set<string>): boolean {
const compatible = FEATURE_COMPATIBILITY[feature];
if (compatible) {
return compatible.some((value) => candidateFeatures.has(value));
}
return candidateFeatures.has(feature);
}
function compareLimit(candidate: number | null, active: number | null): number {
if (active === null) {
return candidate === null ? 0 : -1;
@@ -89,8 +103,8 @@ export function classifyPackageChange(pkg: Package, active: Package | null): Pac
const activeFeatures = collectFeatures(active);
const candidateFeatures = collectFeatures(pkg);
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !activeFeatures.has(feature));
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !candidateFeatures.has(feature));
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !featureSatisfied(feature, activeFeatures));
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !featureSatisfied(feature, candidateFeatures));
const limitKeys: Array<keyof Package> = ['max_photos', 'max_guests', 'gallery_days'];
let hasLimitUpgrade = false;