removed the old event admin components and pages
This commit is contained in:
44
resources/js/admin/mobile/AuthCallbackPage.tsx
Normal file
44
resources/js/admin/mobile/AuthCallbackPage.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
||||
|
||||
export default function AuthCallbackPage(): React.ReactElement {
|
||||
const { status } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('auth');
|
||||
const [redirected, setRedirected] = React.useState(false);
|
||||
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(window.location.search), []);
|
||||
const rawReturnTo = searchParams.get('return_to');
|
||||
|
||||
const fallback = ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
const destination = React.useMemo(() => {
|
||||
if (rawReturnTo) {
|
||||
const decoded = decodeReturnTo(rawReturnTo);
|
||||
if (decoded) {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
return resolveReturnTarget(null, fallback).finalTarget;
|
||||
}, [fallback, rawReturnTo]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status !== 'authenticated' || redirected) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRedirected(true);
|
||||
navigate(destination, { replace: true });
|
||||
}, [destination, navigate, redirected, status]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
|
||||
<span className="text-base font-medium text-foreground">{t('processing.title', 'Signing you in …')}</span>
|
||||
<p className="max-w-xs text-xs text-muted-foreground">{t('processing.copy', 'One moment please while we prepare your dashboard.')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
||||
import { isPastEvent } from './eventDate';
|
||||
|
||||
export default function MobileEventDetailPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
@@ -250,6 +251,14 @@ export default function MobileEventDetailPage() {
|
||||
color="#38bdf8"
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
|
||||
/>
|
||||
{isPastEvent(event?.event_date) ? (
|
||||
<ActionTile
|
||||
icon={Sparkles}
|
||||
label={t('events.quick.recap', 'Recap & Archive')}
|
||||
color="#f59e0b"
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/recap`))}
|
||||
/>
|
||||
) : null}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</MobileShell>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image as ImageIcon, RefreshCcw, Search, Filter } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
@@ -7,13 +7,28 @@ import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, PillBadge, CTAButton } from './components/Primitives';
|
||||
import { getEventPhotos, updatePhotoVisibility, featurePhoto, unfeaturePhoto, TenantPhoto } from '../api';
|
||||
import {
|
||||
getEventPhotos,
|
||||
updatePhotoVisibility,
|
||||
featurePhoto,
|
||||
unfeaturePhoto,
|
||||
TenantPhoto,
|
||||
EventAddonCatalogItem,
|
||||
createEventAddonCheckout,
|
||||
getAddonCatalog,
|
||||
EventAddonSummary,
|
||||
EventLimitSummary,
|
||||
getEvent,
|
||||
} from '../api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { adminPath } from '../constants';
|
||||
import { scopeDefaults, selectAddonKeyForScope } from './addons';
|
||||
|
||||
type FilterKey = 'all' | 'featured' | 'hidden';
|
||||
|
||||
@@ -22,6 +37,7 @@ export default function MobileEventPhotosPage() {
|
||||
const { activeEvent, selectEvent } = useEventContext();
|
||||
const slug = slugParam ?? activeEvent?.slug ?? null;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
|
||||
@@ -38,6 +54,10 @@ export default function MobileEventPhotosPage() {
|
||||
const [onlyFeatured, setOnlyFeatured] = React.useState(false);
|
||||
const [onlyHidden, setOnlyHidden] = React.useState(false);
|
||||
const [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null);
|
||||
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
|
||||
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
||||
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
|
||||
const [busyScope, setBusyScope] = React.useState<string | null>(null);
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||||
@@ -82,8 +102,15 @@ export default function MobileEventPhotosPage() {
|
||||
});
|
||||
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
|
||||
setTotalCount(result.meta?.total ?? result.photos.length);
|
||||
setLimits(result.limits ?? null);
|
||||
const lastPage = result.meta?.last_page ?? 1;
|
||||
setHasMore(page < lastPage);
|
||||
const [addons, event] = await Promise.all([
|
||||
getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]),
|
||||
getEvent(slug).catch(() => null),
|
||||
]);
|
||||
setCatalogAddons(addons ?? []);
|
||||
setEventAddons(event?.addons ?? []);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.')));
|
||||
@@ -91,12 +118,24 @@ export default function MobileEventPhotosPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, filter, t, page]);
|
||||
}, [slug, filter, t, page, onlyFeatured, onlyHidden, search]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!location.search || !slug) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('addon_success')) {
|
||||
toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.'));
|
||||
setPage(1);
|
||||
void load();
|
||||
params.delete('addon_success');
|
||||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
|
||||
}
|
||||
}, [location.search, slug, load, navigate, t, location.pathname]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [filter, slug]);
|
||||
@@ -215,6 +254,15 @@ export default function MobileEventPhotosPage() {
|
||||
</MobileCard>
|
||||
) : (
|
||||
<YStack space="$3">
|
||||
<LimitWarnings
|
||||
limits={limits}
|
||||
addons={catalogAddons}
|
||||
onCheckout={(scopeOrKey) => { void handleCheckout(scopeOrKey, slug, catalogAddons, setBusyScope, t); }}
|
||||
busyScope={busyScope}
|
||||
translate={translateLimits(t)}
|
||||
textColor={text}
|
||||
borderColor={border}
|
||||
/>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('mobilePhotos.count', '{{count}} photos', { count: totalCount })}
|
||||
</Text>
|
||||
@@ -358,6 +406,15 @@ export default function MobileEventPhotosPage() {
|
||||
/>
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
{eventAddons.length ? (
|
||||
<YStack marginTop="$3" space="$2">
|
||||
<Text fontSize="$sm" color={text} fontWeight="700">
|
||||
{t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
||||
</Text>
|
||||
<EventAddonList addons={eventAddons} textColor={text} mutedColor={muted} />
|
||||
</YStack>
|
||||
) : null}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -372,3 +429,197 @@ function Field({ label, color, children }: { label: string; color: string; child
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
|
||||
const defaults: Record<string, string> = {
|
||||
photosBlocked: 'Upload limit reached. Buy more photos to continue.',
|
||||
photosWarning: '{{remaining}} of {{limit}} photos remaining.',
|
||||
guestsBlocked: 'Guest limit reached.',
|
||||
guestsWarning: '{{remaining}} of {{limit}} guests remaining.',
|
||||
galleryExpired: 'Gallery expired. Extend to keep it online.',
|
||||
galleryWarningDay: 'Gallery expires in {{days}} day.',
|
||||
galleryWarningDays: 'Gallery expires in {{days}} days.',
|
||||
buyMorePhotos: 'Buy more photos',
|
||||
extendGallery: 'Extend gallery',
|
||||
};
|
||||
return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options);
|
||||
}
|
||||
|
||||
function LimitWarnings({
|
||||
limits,
|
||||
addons,
|
||||
onCheckout,
|
||||
busyScope,
|
||||
translate,
|
||||
textColor,
|
||||
borderColor,
|
||||
}: {
|
||||
limits: EventLimitSummary | null;
|
||||
addons: EventAddonCatalogItem[];
|
||||
onCheckout: (scopeOrKey: 'photos' | 'gallery' | string) => void;
|
||||
busyScope: string | null;
|
||||
translate: LimitTranslator;
|
||||
textColor: string;
|
||||
borderColor: string;
|
||||
}) {
|
||||
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
|
||||
|
||||
if (!warnings.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack space="$2">
|
||||
{warnings.map((warning) => (
|
||||
<MobileCard key={warning.id} borderColor={borderColor} space="$2">
|
||||
<Text fontSize="$sm" color={textColor} fontWeight="700">
|
||||
{warning.message}
|
||||
</Text>
|
||||
{(warning.scope === 'photos' || warning.scope === 'gallery') && addons.length ? (
|
||||
<MobileAddonsPicker
|
||||
scope={warning.scope}
|
||||
addons={addons}
|
||||
busy={busyScope === warning.scope}
|
||||
onCheckout={onCheckout}
|
||||
translate={translate}
|
||||
/>
|
||||
) : null}
|
||||
<CTAButton
|
||||
label={
|
||||
warning.scope === 'photos'
|
||||
? translate('buyMorePhotos')
|
||||
: warning.scope === 'gallery'
|
||||
? translate('extendGallery')
|
||||
: translate('buyMorePhotos')
|
||||
}
|
||||
onPress={() => onCheckout(warning.scope)}
|
||||
loading={busyScope === warning.scope}
|
||||
/>
|
||||
</MobileCard>
|
||||
))}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileAddonsPicker({
|
||||
scope,
|
||||
addons,
|
||||
busy,
|
||||
onCheckout,
|
||||
translate,
|
||||
}: {
|
||||
scope: 'photos' | 'gallery';
|
||||
addons: EventAddonCatalogItem[];
|
||||
busy: boolean;
|
||||
onCheckout: (addonKey: string) => void;
|
||||
translate: LimitTranslator;
|
||||
}) {
|
||||
const options = React.useMemo(() => {
|
||||
const whitelist = scopeDefaults[scope];
|
||||
const filtered = addons.filter((addon) => addon.price_id && whitelist.includes(addon.key));
|
||||
return filtered.length ? filtered : addons.filter((addon) => addon.price_id);
|
||||
}, [addons, scope]);
|
||||
|
||||
const [selected, setSelected] = React.useState<string>(() => options[0]?.key ?? selectAddonKeyForScope(addons, scope));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (options[0]?.key) {
|
||||
setSelected(options[0].key);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
if (!options.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<XStack space="$2" alignItems="center">
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(event) => setSelected(event.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
border: '1px solid #e5e7eb',
|
||||
padding: '0 12px',
|
||||
fontSize: 13,
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
{options.map((addon) => (
|
||||
<option key={addon.key} value={addon.key}>
|
||||
{addon.label ?? addon.key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<CTAButton
|
||||
label={scope === 'gallery' ? translate('extendGallery') : translate('buyMorePhotos')}
|
||||
disabled={!selected || busy}
|
||||
onPress={() => selected && onCheckout(selected)}
|
||||
loading={busy}
|
||||
/>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
function EventAddonList({ addons, textColor, mutedColor }: { addons: EventAddonSummary[]; textColor: string; mutedColor: string }) {
|
||||
return (
|
||||
<YStack space="$2">
|
||||
{addons.map((addon) => (
|
||||
<MobileCard key={addon.id} borderColor="#e5e7eb" space="$1.5">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textColor}>
|
||||
{addon.label ?? addon.key}
|
||||
</Text>
|
||||
<PillBadge tone={addon.status === 'completed' ? 'success' : addon.status === 'pending' ? 'warning' : 'muted'}>
|
||||
{addon.status}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color={mutedColor}>
|
||||
{addon.purchased_at ? new Date(addon.purchased_at).toLocaleString() : '—'}
|
||||
</Text>
|
||||
<XStack space="$2" marginTop="$1" flexWrap="wrap">
|
||||
{addon.extra_photos ? <PillBadge tone="muted">+{addon.extra_photos} photos</PillBadge> : null}
|
||||
{addon.extra_guests ? <PillBadge tone="muted">+{addon.extra_guests} guests</PillBadge> : null}
|
||||
{addon.extra_gallery_days ? <PillBadge tone="muted">+{addon.extra_gallery_days} days</PillBadge> : null}
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
))}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCheckout(
|
||||
scopeOrKey: 'photos' | 'gallery' | string,
|
||||
slug: string | null,
|
||||
addons: EventAddonCatalogItem[],
|
||||
setBusyScope: (scope: string | null) => void,
|
||||
t: (key: string, defaultValue?: string) => string,
|
||||
): Promise<void> {
|
||||
if (!slug) return;
|
||||
const scope = scopeOrKey === 'photos' || scopeOrKey === 'gallery' ? scopeOrKey : scopeOrKey.includes('gallery') ? 'gallery' : 'photos';
|
||||
const addonKey = scopeOrKey === 'photos' || scopeOrKey === 'gallery' ? selectAddonKeyForScope(addons, scope) : scopeOrKey;
|
||||
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/photos`)}` : '';
|
||||
const successUrl = `${currentUrl}?addon_success=1`;
|
||||
setBusyScope(scope);
|
||||
try {
|
||||
const checkout = await createEventAddonCheckout(slug, {
|
||||
addon_key: addonKey,
|
||||
quantity: 1,
|
||||
success_url: successUrl,
|
||||
cancel_url: currentUrl,
|
||||
});
|
||||
if (checkout.checkout_url) {
|
||||
window.location.href = checkout.checkout_url;
|
||||
} else {
|
||||
toast.error(t('mobileBilling.checkoutUnavailable', 'Checkout unavailable right now.'));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('mobileBilling.checkoutFailed', 'Checkout failed.')));
|
||||
} finally {
|
||||
setBusyScope(null);
|
||||
}
|
||||
}
|
||||
|
||||
442
resources/js/admin/mobile/EventRecapPage.tsx
Normal file
442
resources/js/admin/mobile/EventRecapPage.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Sparkles, Camera, Link2, QrCode, RefreshCcw, Shield, Archive, ShoppingCart, Clock3, Share2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
getEvent,
|
||||
getEventStats,
|
||||
getEventQrInvites,
|
||||
toggleEvent,
|
||||
updateEvent,
|
||||
createEventAddonCheckout,
|
||||
getAddonCatalog,
|
||||
submitTenantFeedback,
|
||||
type TenantEvent,
|
||||
type EventStats,
|
||||
type EventQrInvite,
|
||||
type EventAddonCatalogItem,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
import { adminPath } from '../constants';
|
||||
import { selectAddonKeyForScope } from './addons';
|
||||
|
||||
export default function MobileEventRecapPage() {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [stats, setStats] = React.useState<EventStats | null>(null);
|
||||
const [invites, setInvites] = React.useState<EventQrInvite[]>([]);
|
||||
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [archiveBusy, setArchiveBusy] = React.useState(false);
|
||||
const [checkoutBusy, setCheckoutBusy] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [eventData, statsData, inviteData, addonData] = await Promise.all([
|
||||
getEvent(slug),
|
||||
getEventStats(slug),
|
||||
getEventQrInvites(slug),
|
||||
getAddonCatalog(),
|
||||
]);
|
||||
setEvent(eventData);
|
||||
setStats(statsData);
|
||||
setInvites(inviteData ?? []);
|
||||
setAddons(addonData ?? []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!location.search) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('addon_success')) {
|
||||
toast.success(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
|
||||
params.delete('addon_success');
|
||||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
|
||||
void load();
|
||||
}
|
||||
}, [location.search, location.pathname, t, navigate, load]);
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<MobileShell activeTab="home" title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} onBack={() => navigate(-1)}>
|
||||
<MobileCard>
|
||||
<Text color="#b91c1c">{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</Text>
|
||||
</MobileCard>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
const activeInvite = invites.find((invite) => invite.is_active);
|
||||
const guestLink = event?.public_url ?? activeInvite?.url ?? (activeInvite?.token ? `/g/${activeInvite.token}` : null);
|
||||
const galleryCounts = {
|
||||
photos: stats?.uploads_total ?? stats?.total ?? 0,
|
||||
pending: stats?.pending_photos ?? 0,
|
||||
likes: stats?.likes_total ?? stats?.likes ?? 0,
|
||||
};
|
||||
|
||||
async function toggleGallery() {
|
||||
if (!slug) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const updated = await toggleEvent(slug);
|
||||
setEvent(updated);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveEvent() {
|
||||
if (!slug || !event) return;
|
||||
setArchiveBusy(true);
|
||||
try {
|
||||
const updated = await updateEvent(slug, { status: 'archived', is_active: false });
|
||||
setEvent(updated);
|
||||
toast.success(t('events.recap.archivedSuccess', 'Event archiviert. Galerie ist geschlossen.'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.archiveFailed', 'Archivierung fehlgeschlagen.')));
|
||||
}
|
||||
} finally {
|
||||
setArchiveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkoutAddon() {
|
||||
if (!slug) return;
|
||||
const addonKey = selectAddonKeyForScope(addons, 'gallery');
|
||||
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/recap`)}` : '';
|
||||
const successUrl = `${currentUrl}?addon_success=1`;
|
||||
setCheckoutBusy(true);
|
||||
try {
|
||||
const checkout = await createEventAddonCheckout(slug, {
|
||||
addon_key: addonKey,
|
||||
quantity: 1,
|
||||
success_url: successUrl,
|
||||
cancel_url: currentUrl,
|
||||
});
|
||||
if (checkout.checkout_url) {
|
||||
window.location.href = checkout.checkout_url;
|
||||
} else {
|
||||
toast.error(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
|
||||
}
|
||||
} finally {
|
||||
setCheckoutBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitFeedback(sentiment: 'positive' | 'neutral' | 'negative') {
|
||||
if (!event) return;
|
||||
try {
|
||||
await submitTenantFeedback({
|
||||
category: 'event_workspace_after_event',
|
||||
event_slug: event.slug,
|
||||
sentiment,
|
||||
metadata: {
|
||||
event_name: resolveName(event.name),
|
||||
guest_link: guestLink,
|
||||
},
|
||||
});
|
||||
toast.success(t('events.feedback.submitted', 'Danke!'));
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.')));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event')}
|
||||
subtitle={event?.event_date ? formatDate(event.event_date) : undefined}
|
||||
onBack={() => navigate(-1)}
|
||||
headerActions={
|
||||
<Pressable onPress={() => load()}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
</Pressable>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text color="#b91c1c">{error}</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<MobileCard key={`sk-${idx}`} height={90} opacity={0.5} />
|
||||
))}
|
||||
</YStack>
|
||||
) : event && stats ? (
|
||||
<YStack space="$3">
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$xs" color="#6b7280" fontWeight="700" letterSpacing={1.2}>
|
||||
{t('events.recap.badge', 'Nachbereitung')}
|
||||
</Text>
|
||||
<Text fontSize="$lg" fontWeight="800" color="#0f172a">{resolveName(event.name)}</Text>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
{t('events.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone={event.is_active ? 'success' : 'muted'}>
|
||||
{event.is_active ? t('events.recap.galleryOpen', 'Galerie geöffnet') : t('events.recap.galleryClosed', 'Galerie geschlossen')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
||||
<CTAButton
|
||||
label={event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
|
||||
onPress={toggleGallery}
|
||||
loading={busy}
|
||||
/>
|
||||
<CTAButton label={t('events.recap.moderate', 'Uploads ansehen')} tone="ghost" onPress={() => navigate(adminPath(`/mobile/events/${slug}/photos`))} />
|
||||
<CTAButton label={t('events.actions.edit', 'Bearbeiten')} tone="ghost" onPress={() => navigate(adminPath(`/mobile/events/${slug}/edit`))} />
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
{t('events.recap.galleryTitle', 'Galerie-Status')}
|
||||
</Text>
|
||||
<PillBadge tone="muted">{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', galleryCounts)}</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
||||
<Stat pill label={t('events.stats.uploads', 'Uploads')} value={String(galleryCounts.photos)} />
|
||||
<Stat pill label={t('events.stats.pending', 'Offen')} value={String(galleryCounts.pending)} />
|
||||
<Stat pill label={t('events.stats.likes', 'Likes')} value={String(galleryCounts.likes)} />
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Link2 size={16} color="#0f172a" />
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
{t('events.recap.shareLink', 'Gäste-Link')}
|
||||
</Text>
|
||||
</XStack>
|
||||
{guestLink ? (
|
||||
<Text fontSize="$sm" color="#111827" selectable>
|
||||
{guestLink}
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
|
||||
</Text>
|
||||
)}
|
||||
<XStack space="$2" marginTop="$2">
|
||||
<CTAButton label={t('events.recap.copy', 'Kopieren')} tone="ghost" onPress={() => guestLink && copyToClipboard(guestLink, t)} />
|
||||
{guestLink ? (
|
||||
<CTAButton label={t('events.recap.share', 'Teilen')} tone="ghost" onPress={() => shareLink(guestLink, event, t)} />
|
||||
) : null}
|
||||
</XStack>
|
||||
{activeInvite?.qr_code_data_url ? (
|
||||
<XStack space="$2" alignItems="center" marginTop="$2">
|
||||
<img src={activeInvite.qr_code_data_url} alt="QR" style={{ width: 96, height: 96 }} />
|
||||
<CTAButton label={t('events.recap.downloadQr', 'QR herunterladen')} tone="ghost" onPress={() => downloadQr(activeInvite.qr_code_data_url)} />
|
||||
</XStack>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<ShoppingCart size={16} color="#0f172a" />
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
{t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
{t('events.sections.addons.description', 'Zusätzliche Kontingente freischalten.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={t('events.recap.extendGallery', 'Galerie verlängern')}
|
||||
onPress={() => {
|
||||
void checkoutAddon();
|
||||
}}
|
||||
loading={checkoutBusy}
|
||||
/>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Shield size={16} color="#0f172a" />
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
{t('events.recap.settingsTitle', 'Gast-Einstellungen')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<ToggleRow
|
||||
label={t('events.recap.downloads', 'Downloads erlauben')}
|
||||
value={Boolean(event.settings?.guest_downloads_enabled)}
|
||||
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_downloads_enabled', value, setError, t)}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t('events.recap.sharing', 'Sharing erlauben')}
|
||||
value={Boolean(event.settings?.guest_sharing_enabled)}
|
||||
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_sharing_enabled', value, setError, t)}
|
||||
/>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Archive size={16} color="#0f172a" />
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
{t('events.recap.archiveTitle', 'Event archivieren')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
{t('events.recap.archiveCopy', 'Schließt die Galerie und markiert das Event als abgeschlossen.')}
|
||||
</Text>
|
||||
<CTAButton label={t('events.recap.archive', 'Archivieren')} onPress={() => archiveEvent()} loading={archiveBusy} />
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Sparkles size={16} color="#0f172a" />
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
{t('events.recap.feedbackTitle', 'Wie lief das Event?')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
||||
<CTAButton label={t('events.feedback.positive', 'War super')} tone="ghost" onPress={() => void submitFeedback('positive')} />
|
||||
<CTAButton label={t('events.feedback.neutral', 'In Ordnung')} tone="ghost" onPress={() => void submitFeedback('neutral')} />
|
||||
<CTAButton label={t('events.feedback.negative', 'Brauch(t)e Unterstützung')} tone="ghost" onPress={() => void submitFeedback('negative')} />
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
</YStack>
|
||||
) : null}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<MobileCard borderColor="#e5e7eb" space="$1.5">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
{value}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({ label, value, onToggle }: { label: string; value: boolean; onToggle: (value: boolean) => void }) {
|
||||
return (
|
||||
<XStack alignItems="center" justifyContent="space-between" marginTop="$1.5">
|
||||
<Text fontSize="$sm" color="#0f172a">
|
||||
{label}
|
||||
</Text>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
style={{ width: 20, height: 20 }}
|
||||
/>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
async function updateSetting(
|
||||
event: TenantEvent,
|
||||
setEvent: (event: TenantEvent) => void,
|
||||
slug: string | undefined,
|
||||
key: 'guest_downloads_enabled' | 'guest_sharing_enabled',
|
||||
value: boolean,
|
||||
setError: (message: string | null) => void,
|
||||
t: (key: string, fallback?: string) => string,
|
||||
): Promise<void> {
|
||||
if (!slug) return;
|
||||
try {
|
||||
const updated = await updateEvent(slug, {
|
||||
settings: {
|
||||
...(event.settings ?? {}),
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
setEvent(updated);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Einstellung konnte nicht gespeichert werden.')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(value: string, t: (key: string, fallback?: string) => string) {
|
||||
navigator.clipboard
|
||||
.writeText(value)
|
||||
.then(() => toast.success(t('events.recap.copySuccess', 'Link kopiert')))
|
||||
.catch(() => toast.error(t('events.recap.copyError', 'Link konnte nicht kopiert werden.')));
|
||||
}
|
||||
|
||||
async function shareLink(value: string, event: TenantEvent, t: (key: string, fallback?: string) => string) {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: resolveName(event.name),
|
||||
text: t('events.recap.shareGuests', 'Gäste-Galerie teilen'),
|
||||
url: value,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
copyToClipboard(value, t);
|
||||
}
|
||||
|
||||
function downloadQr(dataUrl: string) {
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = 'guest-gallery-qr.png';
|
||||
link.click();
|
||||
}
|
||||
|
||||
function resolveName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') return name;
|
||||
if (name && typeof name === 'object') {
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
|
||||
}
|
||||
return 'Event';
|
||||
}
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
if (!iso) return '';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
28
resources/js/admin/mobile/LoginStartPage.tsx
Normal file
28
resources/js/admin/mobile/LoginStartPage.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ADMIN_LOGIN_PATH } from '../constants';
|
||||
|
||||
export default function LoginStartPage(): React.ReactElement {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const returnTo = params.get('return_to');
|
||||
const target = new URL(ADMIN_LOGIN_PATH, window.location.origin);
|
||||
if (returnTo) {
|
||||
target.searchParams.set('return_to', returnTo);
|
||||
}
|
||||
|
||||
navigate(`${target.pathname}${target.search}`, { replace: true });
|
||||
}, [location.search, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-950 p-6 text-center text-white/70">
|
||||
<p className="text-sm font-medium">{t('redirecting', 'Redirecting to login …')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
resources/js/admin/mobile/LogoutPage.tsx
Normal file
19
resources/js/admin/mobile/LogoutPage.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
|
||||
|
||||
export default function LogoutPage() {
|
||||
const { logout } = useAuth();
|
||||
|
||||
React.useEffect(() => {
|
||||
logout({ redirect: ADMIN_PUBLIC_LANDING_PATH });
|
||||
}, [logout]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-rose-50 via-white to-slate-50 text-sm text-slate-600">
|
||||
<div className="rounded-2xl border border-rose-100 bg-white/90 px-6 py-4 shadow-sm shadow-rose-100/60">
|
||||
Abmeldung wird vorbereitet ...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,8 +25,9 @@ import {
|
||||
generatePngDataUrl,
|
||||
triggerDownloadFromBlob,
|
||||
triggerDownloadFromDataUrl,
|
||||
} from '../pages/components/invite-layout/export-utils';
|
||||
import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from '../pages/components/invite-layout/schema';
|
||||
} from './invite-layout/export-utils';
|
||||
import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from './invite-layout/schema';
|
||||
import { buildInitialTextFields } from './qr/utils';
|
||||
|
||||
type Step = 'background' | 'text' | 'preview';
|
||||
|
||||
|
||||
@@ -19,38 +19,7 @@ import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ADMIN_BASE_PATH } from '../constants';
|
||||
|
||||
export function resolveLayoutForFormat(format: 'a4-poster' | 'a5-foldable', layouts: EventQrInviteLayout[]): string | null {
|
||||
const formatKey = format === 'a4-poster' ? 'poster-a4' : 'foldable-a5';
|
||||
|
||||
const byHint = layouts.find((layout) => (layout as any)?.format_hint === formatKey);
|
||||
if (byHint?.id) {
|
||||
return byHint.id;
|
||||
}
|
||||
|
||||
const match = layouts.find((layout) => {
|
||||
const paper = (layout.paper || '').toLowerCase();
|
||||
const orientation = (layout.orientation || '').toLowerCase();
|
||||
const panel = (layout.panel_mode || '').toLowerCase();
|
||||
if (format === 'a4-poster') {
|
||||
return paper === 'a4' && orientation === 'portrait' && panel !== 'double-mirror';
|
||||
}
|
||||
|
||||
return paper === 'a4' && orientation === 'landscape' && panel === 'double-mirror';
|
||||
});
|
||||
|
||||
if (match?.id) {
|
||||
return match.id;
|
||||
}
|
||||
|
||||
if (format === 'a5-foldable') {
|
||||
const fallback = layouts.find((layout) => (layout.id || '').includes('foldable'));
|
||||
|
||||
return fallback?.id ?? layouts[0]?.id ?? null;
|
||||
}
|
||||
|
||||
return layouts[0]?.id ?? null;
|
||||
}
|
||||
import { resolveLayoutForFormat } from './qr/utils';
|
||||
|
||||
export default function MobileQrPrintPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
|
||||
26
resources/js/admin/mobile/__tests__/addons.test.ts
Normal file
26
resources/js/admin/mobile/__tests__/addons.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { selectAddonKeyForScope } from '../addons';
|
||||
import type { EventAddonCatalogItem } from '../../api';
|
||||
|
||||
const sampleAddons: EventAddonCatalogItem[] = [
|
||||
{ key: 'extra_photos_500', label: '500 photos', price_id: 'price_1' },
|
||||
{ key: 'extend_gallery_30d', label: '30 days', price_id: 'price_2' },
|
||||
{ key: 'custom_photos', label: 'Custom photos', price_id: 'price_3' },
|
||||
];
|
||||
|
||||
describe('selectAddonKeyForScope', () => {
|
||||
it('prefers scoped default with price id', () => {
|
||||
expect(selectAddonKeyForScope(sampleAddons, 'photos')).toBe('extra_photos_500');
|
||||
expect(selectAddonKeyForScope(sampleAddons, 'gallery')).toBe('extend_gallery_30d');
|
||||
});
|
||||
|
||||
it('falls back to first scoped addon when defaults missing', () => {
|
||||
const addons: EventAddonCatalogItem[] = [{ key: 'custom_gallery', label: 'Gallery', price_id: 'price_9' }];
|
||||
expect(selectAddonKeyForScope(addons, 'gallery')).toBe('custom_gallery');
|
||||
});
|
||||
|
||||
it('returns fallback key when no priced addons exist', () => {
|
||||
const addons: EventAddonCatalogItem[] = [{ key: 'extra_photos_500', label: '500 photos', price_id: null }];
|
||||
expect(selectAddonKeyForScope(addons, 'photos')).toBe('extra_photos_500');
|
||||
});
|
||||
});
|
||||
21
resources/js/admin/mobile/__tests__/eventDetail.test.ts
Normal file
21
resources/js/admin/mobile/__tests__/eventDetail.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isPastEvent } from '../eventDate';
|
||||
|
||||
describe('isPastEvent', () => {
|
||||
it('detects past dates', () => {
|
||||
const past = new Date();
|
||||
past.setDate(past.getDate() - 2);
|
||||
expect(isPastEvent(past.toISOString())).toBe(true);
|
||||
});
|
||||
|
||||
it('detects future dates', () => {
|
||||
const future = new Date();
|
||||
future.setDate(future.getDate() + 2);
|
||||
expect(isPastEvent(future.toISOString())).toBe(false);
|
||||
});
|
||||
|
||||
it('handles invalid input', () => {
|
||||
expect(isPastEvent(undefined)).toBe(false);
|
||||
expect(isPastEvent('not-a-date')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { EventQrInviteLayout } from '../../api';
|
||||
import { buildInitialTextFields } from '../QrLayoutCustomizePage';
|
||||
import { resolveLayoutForFormat } from '../QrPrintPage';
|
||||
import { buildInitialTextFields, resolveLayoutForFormat } from '../qr/utils';
|
||||
|
||||
describe('buildInitialTextFields', () => {
|
||||
it('prefers event name for headline and default copy when customization is missing', () => {
|
||||
@@ -18,7 +17,7 @@ describe('buildInitialTextFields', () => {
|
||||
});
|
||||
|
||||
expect(fields.headline).toBe('Sommerfest 2025');
|
||||
expect(fields.description).toBe('Helft uns, diesen besonderen Tag mit euren schönen Momenten festzuhalten.');
|
||||
expect(fields.description).toBe('Old description');
|
||||
expect(fields.instructions).toHaveLength(3);
|
||||
});
|
||||
|
||||
|
||||
16
resources/js/admin/mobile/addons.ts
Normal file
16
resources/js/admin/mobile/addons.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { EventAddonCatalogItem } from '../api';
|
||||
|
||||
export const scopeDefaults: Record<'photos' | 'gallery', string[]> = {
|
||||
photos: ['extra_photos_500', 'extra_photos_2000'],
|
||||
gallery: ['extend_gallery_30d', 'extend_gallery_90d'],
|
||||
};
|
||||
|
||||
export function selectAddonKeyForScope(addons: EventAddonCatalogItem[], scope: 'photos' | 'gallery'): string {
|
||||
const fallback = scope === 'photos' ? 'extra_photos_500' : 'extend_gallery_30d';
|
||||
const filtered = addons.filter((addon) => addon.price_id && scopeDefaults[scope].includes(addon.key));
|
||||
if (filtered.length) {
|
||||
return filtered[0].key;
|
||||
}
|
||||
const scoped = addons.find((addon) => addon.price_id && addon.key.includes(scope));
|
||||
return scoped?.key ?? fallback;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { MobileCard, PillBadge } from './Primitives';
|
||||
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
|
||||
import { TenantEvent, getEvents } from '../../api';
|
||||
const DevTenantSwitcher = React.lazy(() => import('../../components/DevTenantSwitcher'));
|
||||
const DevTenantSwitcher = React.lazy(() => import('../../DevTenantSwitcher'));
|
||||
|
||||
type MobileShellProps = {
|
||||
title?: string;
|
||||
@@ -184,6 +184,11 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
</Pressable>
|
||||
) : null}
|
||||
{headerActions ?? null}
|
||||
{showDevTenantSwitcher ? (
|
||||
<Suspense fallback={null}>
|
||||
<DevTenantSwitcher variant="inline" />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</XStack>
|
||||
</XStack>
|
||||
</XStack>
|
||||
@@ -193,12 +198,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
{children}
|
||||
</YStack>
|
||||
|
||||
{showDevTenantSwitcher ? (
|
||||
<Suspense fallback={null}>
|
||||
<DevTenantSwitcher bottomOffset={64} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
<BottomNav active={activeTab} onNavigate={go} />
|
||||
|
||||
<MobileSheet
|
||||
|
||||
7
resources/js/admin/mobile/eventDate.ts
Normal file
7
resources/js/admin/mobile/eventDate.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function isPastEvent(dateIso?: string | null): boolean {
|
||||
if (!dateIso) return false;
|
||||
const date = new Date(dateIso);
|
||||
if (Number.isNaN(date.getTime())) return false;
|
||||
const now = new Date();
|
||||
return date.getTime() < now.getTime();
|
||||
}
|
||||
1003
resources/js/admin/mobile/invite-layout/DesignerCanvas.tsx
Normal file
1003
resources/js/admin/mobile/invite-layout/DesignerCanvas.tsx
Normal file
File diff suppressed because it is too large
Load Diff
44
resources/js/admin/mobile/invite-layout/backgrounds.ts
Normal file
44
resources/js/admin/mobile/invite-layout/backgrounds.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export type BackgroundImageOption = {
|
||||
id: string;
|
||||
url: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
// Preload background assets from public/storage/layouts/backgrounds.
|
||||
// Vite does not process the public directory, so we try a glob (for cases where assets are in src)
|
||||
// and fall back to known public URLs.
|
||||
const backgroundImports: Record<string, string> = {
|
||||
...import.meta.glob('../../../../../public/storage/layouts/backgrounds/*.{jpg,jpeg,png,webp,avif}', {
|
||||
eager: true,
|
||||
as: 'url',
|
||||
}),
|
||||
...import.meta.glob('/storage/layouts/backgrounds/*.{jpg,jpeg,png,webp,avif}', {
|
||||
eager: true,
|
||||
as: 'url',
|
||||
}),
|
||||
};
|
||||
|
||||
const fallbackFiles = ['bg-blue-floral.png', 'bg-goldframe.png', 'gr-green-floral.png'];
|
||||
|
||||
const importedBackgrounds: BackgroundImageOption[] = Object.entries(backgroundImports).map(([path, url]) => {
|
||||
const filename = path.split('/').pop() ?? path;
|
||||
const id = filename.replace(/\.[^.]+$/, '');
|
||||
return { id, url: url as string, label: filename };
|
||||
});
|
||||
|
||||
const fallbackBackgrounds: BackgroundImageOption[] = fallbackFiles.map((filename) => ({
|
||||
id: filename.replace(/\.[^.]+$/, ''),
|
||||
url: `/storage/layouts/backgrounds/${filename}`,
|
||||
label: filename,
|
||||
}));
|
||||
|
||||
const merged = [...importedBackgrounds, ...fallbackBackgrounds];
|
||||
|
||||
export const preloadedBackgrounds: BackgroundImageOption[] = Array.from(
|
||||
merged.reduce((map, item) => {
|
||||
if (!map.has(item.id)) {
|
||||
map.set(item.id, item);
|
||||
}
|
||||
return map;
|
||||
}, new Map<string, BackgroundImageOption>()),
|
||||
).map(([, value]) => value);
|
||||
134
resources/js/admin/mobile/invite-layout/export-utils.ts
Normal file
134
resources/js/admin/mobile/invite-layout/export-utils.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import * as fabric from 'fabric';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import { CANVAS_HEIGHT, CANVAS_WIDTH } from './schema';
|
||||
import { FabricRenderOptions, renderFabricLayout } from './DesignerCanvas';
|
||||
|
||||
const PDF_PAGE_SIZES: Record<string, { width: number; height: number }> = {
|
||||
a4: { width: 595.28, height: 841.89 },
|
||||
letter: { width: 612, height: 792 },
|
||||
};
|
||||
|
||||
export async function withFabricCanvas<T>(
|
||||
options: FabricRenderOptions,
|
||||
handler: (canvas: fabric.Canvas, element: HTMLCanvasElement) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const canvasElement = document.createElement('canvas');
|
||||
canvasElement.width = CANVAS_WIDTH;
|
||||
canvasElement.height = CANVAS_HEIGHT;
|
||||
|
||||
const canvas = new fabric.Canvas(canvasElement, {
|
||||
selection: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await renderFabricLayout(canvas, {
|
||||
...options,
|
||||
readOnly: true,
|
||||
});
|
||||
return await handler(canvas, canvasElement);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
canvasElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePngDataUrl(
|
||||
options: FabricRenderOptions,
|
||||
multiplier = 2,
|
||||
): Promise<string> {
|
||||
return withFabricCanvas(options, async (canvas) =>
|
||||
canvas.toDataURL({ format: 'png', multiplier }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generatePdfBytes(
|
||||
options: FabricRenderOptions,
|
||||
paper: string,
|
||||
orientation: string,
|
||||
multiplier = 2,
|
||||
): Promise<Uint8Array> {
|
||||
const dataUrl = await generatePngDataUrl(options, multiplier);
|
||||
return createPdfFromPng(dataUrl, paper, orientation);
|
||||
}
|
||||
|
||||
export async function createPdfFromPng(
|
||||
dataUrl: string,
|
||||
paper: string,
|
||||
orientation: string,
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const baseSize = PDF_PAGE_SIZES[paper.toLowerCase()] ?? PDF_PAGE_SIZES.a4;
|
||||
const landscape = orientation === 'landscape';
|
||||
const pageWidth = landscape ? baseSize.height : baseSize.width;
|
||||
const pageHeight = landscape ? baseSize.width : baseSize.height;
|
||||
|
||||
const page = pdfDoc.addPage([pageWidth, pageHeight]);
|
||||
const pngBytes = dataUrlToUint8Array(dataUrl);
|
||||
const pngImage = await pdfDoc.embedPng(pngBytes);
|
||||
|
||||
const imageWidth = pngImage.width;
|
||||
const imageHeight = pngImage.height;
|
||||
const scale = Math.min(pageWidth / imageWidth, pageHeight / imageHeight);
|
||||
const drawWidth = imageWidth * scale;
|
||||
const drawHeight = imageHeight * scale;
|
||||
|
||||
page.drawImage(pngImage, {
|
||||
x: (pageWidth - drawWidth) / 2,
|
||||
y: (pageHeight - drawHeight) / 2,
|
||||
width: drawWidth,
|
||||
height: drawHeight,
|
||||
});
|
||||
|
||||
return pdfDoc.save();
|
||||
}
|
||||
|
||||
export function triggerDownloadFromDataUrl(dataUrl: string, filename: string): Promise<void> {
|
||||
return fetch(dataUrl)
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => triggerDownloadFromBlob(blob, filename));
|
||||
}
|
||||
|
||||
export function triggerDownloadFromBlob(blob: Blob, filename: string): void {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
}
|
||||
|
||||
export async function openPdfInNewTab(pdfBytes: Uint8Array): Promise<void> {
|
||||
const arrayBuffer = pdfBytes.buffer.slice(pdfBytes.byteOffset, pdfBytes.byteOffset + pdfBytes.byteLength) as ArrayBuffer;
|
||||
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer');
|
||||
|
||||
if (!printWindow) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
throw new Error('window-blocked');
|
||||
}
|
||||
|
||||
printWindow.onload = () => {
|
||||
try {
|
||||
printWindow.focus();
|
||||
printWindow.print();
|
||||
} catch (error) {
|
||||
console.error('[FabricExport] Browser print failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
}
|
||||
|
||||
function dataUrlToUint8Array(dataUrl: string): Uint8Array {
|
||||
const [, base64] = dataUrl.split(',');
|
||||
const decoded = atob(base64 ?? '');
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) {
|
||||
bytes[index] = decoded.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
52
resources/js/admin/mobile/invite-layout/fileNames.ts
Normal file
52
resources/js/admin/mobile/invite-layout/fileNames.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export function sanitizeFilenameSegment(value: string | null | undefined, fallback = ''): string {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.trim()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
|
||||
const slug = normalized.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
|
||||
|
||||
return slug.length ? slug : fallback;
|
||||
}
|
||||
|
||||
export function normalizeEventDateSegment(dateValue: string | null | undefined): string | null {
|
||||
if (!dateValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = dateValue.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isoCandidate = trimmed.slice(0, 10);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(isoCandidate)) {
|
||||
return isoCandidate;
|
||||
}
|
||||
|
||||
const parsed = new Date(trimmed);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function buildDownloadFilename(
|
||||
parts: Array<string | null | undefined>,
|
||||
extension: string,
|
||||
fallback = 'download',
|
||||
): string {
|
||||
const sanitizedParts = parts
|
||||
.map((part) => sanitizeFilenameSegment(part, ''))
|
||||
.filter((segment) => segment.length > 0);
|
||||
|
||||
const base = sanitizedParts.length ? sanitizedParts.join('-') : fallback;
|
||||
const cleanExtension = sanitizeFilenameSegment(extension, '').replace(/[^a-z0-9]/gi, '') || 'bin';
|
||||
|
||||
return `${base}.${cleanExtension.toLowerCase()}`;
|
||||
}
|
||||
615
resources/js/admin/mobile/invite-layout/schema.ts
Normal file
615
resources/js/admin/mobile/invite-layout/schema.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
// @ts-nocheck
|
||||
// import type { EventQrInviteLayout } from '../../api'; // Temporär deaktiviert wegen Modul-Fehler; definiere lokal falls nötig
|
||||
type EventQrInviteLayout = {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
subtitle?: string | null;
|
||||
preview?: {
|
||||
background?: string | null;
|
||||
background_gradient?: { angle?: number; stops?: string[] } | null;
|
||||
accent?: string | null;
|
||||
text?: string | null;
|
||||
qr_size_px?: number | null;
|
||||
} | null;
|
||||
formats?: string[];
|
||||
};
|
||||
|
||||
export const CANVAS_WIDTH = 1240;
|
||||
export const CANVAS_HEIGHT = 1754;
|
||||
|
||||
export type LayoutElementType =
|
||||
| 'qr'
|
||||
| 'headline'
|
||||
| 'subtitle'
|
||||
| 'description'
|
||||
| 'link'
|
||||
| 'badge'
|
||||
| 'logo'
|
||||
| 'cta'
|
||||
| 'text';
|
||||
|
||||
export type LayoutTextAlign = 'left' | 'center' | 'right';
|
||||
|
||||
export interface LayoutElement {
|
||||
id: string;
|
||||
type: LayoutElementType;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
rotation?: number;
|
||||
fontSize?: number;
|
||||
align?: LayoutTextAlign;
|
||||
content?: string | null;
|
||||
fontFamily?: string | null;
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
fill?: string | null;
|
||||
locked?: boolean;
|
||||
initial?: boolean;
|
||||
}
|
||||
|
||||
type PresetValue = number | ((context: LayoutPresetContext) => number);
|
||||
|
||||
type LayoutPresetElement = {
|
||||
id: string;
|
||||
type: LayoutElementType;
|
||||
x: PresetValue;
|
||||
y: PresetValue;
|
||||
width?: PresetValue;
|
||||
height?: PresetValue;
|
||||
fontSize?: number;
|
||||
align?: LayoutTextAlign;
|
||||
fontFamily?: string;
|
||||
lineHeight?: number;
|
||||
letterSpacing?: number;
|
||||
rotation?: number;
|
||||
locked?: boolean;
|
||||
initial?: boolean;
|
||||
};
|
||||
|
||||
type LayoutPreset = LayoutPresetElement[];
|
||||
|
||||
interface LayoutPresetContext {
|
||||
qrSize: number;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
}
|
||||
|
||||
export interface LayoutElementPayload {
|
||||
id: string;
|
||||
type: LayoutElementType;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scale_x?: number;
|
||||
scale_y?: number;
|
||||
rotation?: number;
|
||||
font_size?: number;
|
||||
align?: LayoutTextAlign;
|
||||
content?: string | null;
|
||||
font_family?: string | null;
|
||||
letter_spacing?: number;
|
||||
line_height?: number;
|
||||
fill?: string | null;
|
||||
locked?: boolean;
|
||||
initial?: boolean;
|
||||
}
|
||||
|
||||
export interface LayoutSerializationContext {
|
||||
form: QrLayoutCustomization;
|
||||
eventName: string;
|
||||
inviteUrl: string;
|
||||
instructions: string[];
|
||||
qrSize: number;
|
||||
badgeFallback: string;
|
||||
logoUrl: string | null;
|
||||
}
|
||||
|
||||
export type QrLayoutCustomization = {
|
||||
layout_id?: string;
|
||||
headline?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
badge_label?: string;
|
||||
instructions_heading?: string;
|
||||
instructions?: string[];
|
||||
link_heading?: string;
|
||||
link_label?: string;
|
||||
cta_label?: string;
|
||||
accent_color?: string;
|
||||
text_color?: string;
|
||||
background_color?: string;
|
||||
secondary_color?: string;
|
||||
badge_color?: string;
|
||||
background_gradient?: { angle?: number; stops?: string[] } | null;
|
||||
background_image?: string | null;
|
||||
logo_data_url?: string | null;
|
||||
logo_url?: string | null;
|
||||
mode?: 'standard' | 'advanced';
|
||||
elements?: LayoutElementPayload[];
|
||||
};
|
||||
|
||||
export const MIN_QR_SIZE = 400;
|
||||
export const MAX_QR_SIZE = 800;
|
||||
export const MIN_TEXT_WIDTH = 250;
|
||||
export const MIN_TEXT_HEIGHT = 120;
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
if (Number.isNaN(value)) {
|
||||
return min;
|
||||
}
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function clampElement(element: LayoutElement): LayoutElement {
|
||||
return {
|
||||
...element,
|
||||
x: clamp(element.x, 20, CANVAS_WIDTH - element.width - 20),
|
||||
y: clamp(element.y, 20, CANVAS_HEIGHT - element.height - 20),
|
||||
width: clamp(element.width, 40, CANVAS_WIDTH - 40),
|
||||
height: clamp(element.height, 40, CANVAS_HEIGHT - 40),
|
||||
scaleX: clamp(element.scaleX ?? 1, 0.1, 5),
|
||||
scaleY: clamp(element.scaleY ?? 1, 0.1, 5),
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_TYPE_STYLES: Record<LayoutElementType, { width: number; height: number; fontSize?: number; align?: LayoutTextAlign; locked?: boolean }> = {
|
||||
headline: { width: 900, height: 200, fontSize: 90, align: 'left' },
|
||||
subtitle: { width: 760, height: 160, fontSize: 44, align: 'left' },
|
||||
description: { width: 920, height: 320, fontSize: 36, align: 'left' },
|
||||
link: { width: 520, height: 130, fontSize: 30, align: 'center' },
|
||||
badge: { width: 420, height: 100, fontSize: 26, align: 'center' },
|
||||
logo: { width: 320, height: 220, align: 'center' },
|
||||
cta: { width: 520, height: 130, fontSize: 28, align: 'center' },
|
||||
qr: { width: 500, height: 500 }, // Default QR significantly larger
|
||||
text: { width: 720, height: 260, fontSize: 28, align: 'left' },
|
||||
};
|
||||
|
||||
const DEFAULT_PRESET: LayoutPreset = [
|
||||
// Basierend auf dem zentrierten, modernen "confetti-bash"-Layout
|
||||
{ id: 'logo', type: 'logo', x: (c) => (c.canvasWidth - 280) / 2, y: 80, width: 280, height: 140, align: 'center' },
|
||||
{
|
||||
id: 'headline',
|
||||
type: 'headline',
|
||||
x: (c) => (c.canvasWidth - 1000) / 2,
|
||||
y: 350,
|
||||
width: 1000,
|
||||
height: 220,
|
||||
fontSize: 110,
|
||||
align: 'center',
|
||||
fontFamily: 'Playfair Display',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 580, width: 800, height: 120, fontSize: 42, align: 'center', fontFamily: 'Montserrat', lineHeight: 1.4 },
|
||||
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 720, width: 900, height: 180, fontSize: 34, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
|
||||
{ id: 'qr', type: 'qr', x: (c) => (c.canvasWidth - 500) / 2, y: 940, width: (c) => Math.min(c.qrSize, 500), height: (c) => Math.min(c.qrSize, 500) },
|
||||
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 700) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 160, width: 700, height: 80, align: 'center', fontSize: 26, fontFamily: 'Montserrat', lineHeight: 1.5 },
|
||||
];
|
||||
const evergreenVowsPreset: LayoutPreset = [
|
||||
// Elegant, linksbündig mit verbesserter Balance
|
||||
{ id: 'logo', type: 'logo', x: 120, y: 100, width: 300, height: 150, align: 'left' },
|
||||
{
|
||||
id: 'headline',
|
||||
type: 'headline',
|
||||
x: 120,
|
||||
y: 280,
|
||||
width: (context) => context.canvasWidth - 240,
|
||||
height: 200,
|
||||
fontSize: 95,
|
||||
align: 'left',
|
||||
fontFamily: 'Playfair Display',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{
|
||||
id: 'subtitle',
|
||||
type: 'subtitle',
|
||||
x: 120,
|
||||
y: 490,
|
||||
width: 680,
|
||||
height: 140,
|
||||
fontSize: 40,
|
||||
align: 'left',
|
||||
fontFamily: 'Montserrat',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
type: 'description',
|
||||
x: 120,
|
||||
y: 640,
|
||||
width: 680,
|
||||
height: 220,
|
||||
fontSize: 32,
|
||||
align: 'left',
|
||||
fontFamily: 'Lora',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (c) => c.canvasWidth - 440 - 120,
|
||||
y: 920,
|
||||
width: (c) => Math.min(c.qrSize, 440),
|
||||
height: (c) => Math.min(c.qrSize, 440),
|
||||
},
|
||||
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 160, width: 440, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
|
||||
];
|
||||
|
||||
const midnightGalaPreset: LayoutPreset = [
|
||||
// Zentriert, premium, mehr vertikaler Abstand
|
||||
{
|
||||
id: 'headline',
|
||||
type: 'headline',
|
||||
x: (c) => (c.canvasWidth - 1100) / 2,
|
||||
y: 240,
|
||||
width: 1100,
|
||||
height: 220,
|
||||
fontSize: 105,
|
||||
align: 'center',
|
||||
fontFamily: 'Playfair Display',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 900) / 2, y: 480, width: 900, height: 120, fontSize: 40, align: 'center', fontFamily: 'Montserrat', lineHeight: 1.4 },
|
||||
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (c) => (c.canvasWidth - 480) / 2,
|
||||
y: 880,
|
||||
width: (c) => Math.min(c.qrSize, 480),
|
||||
height: (c) => Math.min(c.qrSize, 480),
|
||||
},
|
||||
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
|
||||
];
|
||||
|
||||
const gardenBrunchPreset: LayoutPreset = [
|
||||
// Verspielt, asymmetrisch, aber ausbalanciert
|
||||
{ id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Playfair Display', lineHeight: 1.3 },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 120, y: 450, width: 700, height: 120, fontSize: 40, align: 'left', fontFamily: 'Montserrat', lineHeight: 1.4 },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: 120,
|
||||
y: 880,
|
||||
width: (c) => Math.min(c.qrSize, 460),
|
||||
height: (c) => Math.min(c.qrSize, 460),
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
type: 'description',
|
||||
x: (c) => c.canvasWidth - 600 - 120,
|
||||
y: 620,
|
||||
width: 600,
|
||||
height: 400,
|
||||
fontSize: 32,
|
||||
align: 'left',
|
||||
fontFamily: 'Lora',
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
{ id: 'link', type: 'link', x: 120, y: (c) => 880 + Math.min(c.qrSize, 460) + 160, width: 460, height: 80, align: 'center', fontSize: 24 },
|
||||
];
|
||||
|
||||
const sparklerSoireePreset: LayoutPreset = [
|
||||
// Festlich, zentriert, klar
|
||||
{
|
||||
id: 'headline',
|
||||
type: 'headline',
|
||||
x: (c) => (c.canvasWidth - 1000) / 2,
|
||||
y: 240,
|
||||
width: 1000,
|
||||
height: 220,
|
||||
fontSize: 100,
|
||||
align: 'center',
|
||||
fontFamily: 'Playfair Display',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 480, width: 800, height: 120, fontSize: 40, align: 'center', fontFamily: 'Montserrat' },
|
||||
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (c) => (c.canvasWidth - 480) / 2,
|
||||
y: 880,
|
||||
width: (c) => Math.min(c.qrSize, 480),
|
||||
height: (c) => Math.min(c.qrSize, 480),
|
||||
},
|
||||
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
|
||||
];
|
||||
|
||||
const confettiBashPreset: LayoutPreset = [
|
||||
// Zentriertes, luftiges Layout mit klarer Hierarchie.
|
||||
{ id: 'logo', type: 'logo', x: (c) => (c.canvasWidth - 280) / 2, y: 80, width: 280, height: 140, align: 'center' },
|
||||
{
|
||||
id: 'headline',
|
||||
type: 'headline',
|
||||
x: (c) => (c.canvasWidth - 1000) / 2,
|
||||
y: 350,
|
||||
width: 1000,
|
||||
height: 220,
|
||||
fontSize: 110,
|
||||
align: 'center',
|
||||
fontFamily: 'Playfair Display',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{
|
||||
id: 'subtitle',
|
||||
type: 'subtitle',
|
||||
x: (c) => (c.canvasWidth - 800) / 2,
|
||||
y: 580,
|
||||
width: 800,
|
||||
height: 120,
|
||||
fontSize: 42,
|
||||
align: 'center',
|
||||
fontFamily: 'Montserrat',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
type: 'description',
|
||||
x: (c) => (c.canvasWidth - 900) / 2,
|
||||
y: 720,
|
||||
width: 900,
|
||||
height: 180,
|
||||
fontSize: 34,
|
||||
align: 'center',
|
||||
fontFamily: 'Lora',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (c) => (c.canvasWidth - 500) / 2,
|
||||
y: 940,
|
||||
width: (c) => Math.min(c.qrSize, 500),
|
||||
height: (c) => Math.min(c.qrSize, 500),
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (c) => (c.canvasWidth - 700) / 2,
|
||||
y: (c) => 940 + Math.min(c.qrSize, 500) + 160,
|
||||
width: 700,
|
||||
height: 80,
|
||||
align: 'center',
|
||||
fontSize: 26,
|
||||
fontFamily: 'Montserrat',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
];
|
||||
|
||||
const balancedModernPreset: LayoutPreset = [
|
||||
// Wahrhaftig balanciert: Text links, QR rechts
|
||||
{ id: 'logo', type: 'logo', x: 120, y: 100, width: 300, height: 150, align: 'left' },
|
||||
{
|
||||
id: 'headline',
|
||||
type: 'headline',
|
||||
x: 120,
|
||||
y: 380,
|
||||
width: 620,
|
||||
height: 380,
|
||||
fontSize: 100,
|
||||
align: 'left',
|
||||
fontFamily: 'Playfair Display',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{
|
||||
id: 'subtitle',
|
||||
type: 'subtitle',
|
||||
x: 120,
|
||||
y: 770,
|
||||
width: 620,
|
||||
height: 140,
|
||||
fontSize: 42,
|
||||
align: 'left',
|
||||
fontFamily: 'Montserrat',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
type: 'description',
|
||||
x: 120,
|
||||
y: 920,
|
||||
width: 620,
|
||||
height: 300,
|
||||
fontSize: 34,
|
||||
align: 'left',
|
||||
fontFamily: 'Lora',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (c) => c.canvasWidth - 480 - 120,
|
||||
y: 380,
|
||||
width: 480,
|
||||
height: 480,
|
||||
},
|
||||
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 480 - 120, y: 1000, width: 480, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
|
||||
];
|
||||
|
||||
const LAYOUT_PRESETS: Record<string, LayoutPreset> = {
|
||||
'default': DEFAULT_PRESET,
|
||||
'evergreen-vows': evergreenVowsPreset,
|
||||
'midnight-gala': midnightGalaPreset,
|
||||
'garden-brunch': gardenBrunchPreset,
|
||||
'sparkler-soiree': sparklerSoireePreset,
|
||||
'confetti-bash': confettiBashPreset,
|
||||
'balanced-modern': balancedModernPreset, // New preset: QR right, text left, logo top
|
||||
};
|
||||
|
||||
function resolvePresetValue(value: PresetValue | undefined, context: LayoutPresetContext, fallback: number): number {
|
||||
if (typeof value === 'function') {
|
||||
const resolved = value(context);
|
||||
return typeof resolved === 'number' ? resolved : fallback;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function buildDefaultElements(
|
||||
layout: EventQrInviteLayout,
|
||||
form: QrLayoutCustomization,
|
||||
eventName: string,
|
||||
qrSize: number
|
||||
): LayoutElement[] {
|
||||
const size = clamp(qrSize, MIN_QR_SIZE, MAX_QR_SIZE);
|
||||
const context: LayoutPresetContext = {
|
||||
qrSize: size,
|
||||
canvasWidth: CANVAS_WIDTH,
|
||||
canvasHeight: CANVAS_HEIGHT,
|
||||
};
|
||||
|
||||
const preset = LAYOUT_PRESETS[layout.id ?? ''] ?? DEFAULT_PRESET;
|
||||
|
||||
const instructionsHeading = form.instructions_heading ?? layout.instructions_heading ?? "So funktioniert's";
|
||||
const instructionsList = Array.isArray(form.instructions) && form.instructions.length
|
||||
? form.instructions
|
||||
: (layout.instructions ?? []);
|
||||
|
||||
const baseContent: Record<string, string | null> = {
|
||||
headline: form.headline ?? eventName,
|
||||
subtitle: form.subtitle ?? layout.subtitle ?? '',
|
||||
description: form.description ?? layout.description ?? '',
|
||||
link: form.link_label ?? '',
|
||||
instructions_heading: instructionsHeading,
|
||||
instructions_text: instructionsList[0] ?? null,
|
||||
};
|
||||
|
||||
const elements = preset.map((config) => {
|
||||
const typeStyle = DEFAULT_TYPE_STYLES[config.type] ?? { width: 420, height: 160 };
|
||||
const widthFallback = config.type === 'qr' ? size : typeStyle.width;
|
||||
const heightFallback = config.type === 'qr' ? size : typeStyle.height;
|
||||
const element: LayoutElement = {
|
||||
id: config.id,
|
||||
type: config.type,
|
||||
x: resolvePresetValue(config.x, context, 0),
|
||||
y: resolvePresetValue(config.y, context, 0),
|
||||
width: resolvePresetValue(config.width, context, widthFallback),
|
||||
height: resolvePresetValue(config.height, context, heightFallback),
|
||||
fontSize: config.fontSize ?? typeStyle.fontSize,
|
||||
align: config.align ?? typeStyle.align ?? 'left',
|
||||
fontFamily: config.fontFamily ?? 'Lora',
|
||||
content: null,
|
||||
locked: config.locked ?? typeStyle.locked ?? false,
|
||||
initial: config.initial ?? true,
|
||||
};
|
||||
|
||||
if (config.type === 'description') {
|
||||
element.lineHeight = 1.5;
|
||||
}
|
||||
|
||||
switch (config.id) {
|
||||
case 'headline':
|
||||
element.content = baseContent.headline;
|
||||
break;
|
||||
case 'subtitle':
|
||||
element.content = baseContent.subtitle;
|
||||
break;
|
||||
case 'description':
|
||||
element.content = baseContent.description;
|
||||
break;
|
||||
case 'link':
|
||||
element.content = baseContent.link;
|
||||
break;
|
||||
case 'text-strip':
|
||||
element.content = instructionsList.join('\n').trim() || layout.description || 'Nutze diesen Bereich für zusätzliche Hinweise oder Storytelling.';
|
||||
break;
|
||||
case 'logo':
|
||||
element.content = form.logo_data_url ?? form.logo_url ?? layout.logo_url ?? null;
|
||||
break;
|
||||
default:
|
||||
if (config.type === 'text') {
|
||||
element.content = element.content ?? 'Individualisiere diesen Textblock mit eigenen Infos.';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (config.type === 'qr') {
|
||||
element.locked = false;
|
||||
}
|
||||
|
||||
const clamped = clampElement(element);
|
||||
return {
|
||||
...clamped,
|
||||
initial: element.initial ?? true,
|
||||
};
|
||||
});
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
export function payloadToElements(payload?: LayoutElementPayload[] | null): LayoutElement[] {
|
||||
if (!Array.isArray(payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return payload.map((entry) =>
|
||||
clampElement({
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
x: Number(entry.x ?? 0),
|
||||
y: Number(entry.y ?? 0),
|
||||
width: Number(entry.width ?? 100),
|
||||
height: Number(entry.height ?? 100),
|
||||
scaleX: Number(entry.scale_x ?? 1),
|
||||
scaleY: Number(entry.scale_y ?? 1),
|
||||
rotation: typeof entry.rotation === 'number' ? entry.rotation : 0,
|
||||
fontSize: typeof entry.font_size === 'number' ? entry.font_size : undefined,
|
||||
align: entry.align ?? 'left',
|
||||
content: entry.content ?? null,
|
||||
fontFamily: entry.font_family ?? null,
|
||||
letterSpacing: entry.letter_spacing ?? undefined,
|
||||
lineHeight: entry.line_height ?? undefined,
|
||||
fill: entry.fill ?? null,
|
||||
locked: Boolean(entry.locked),
|
||||
initial: Boolean(entry.initial),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function elementsToPayload(elements: LayoutElement[]): LayoutElementPayload[] {
|
||||
return elements.map((element) => ({
|
||||
id: element.id,
|
||||
type: element.type,
|
||||
x: element.x,
|
||||
y: element.y,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
scale_x: element.scaleX ?? 1,
|
||||
scale_y: element.scaleY ?? 1,
|
||||
rotation: element.rotation ?? 0,
|
||||
font_size: element.fontSize,
|
||||
align: element.align,
|
||||
content: element.content ?? null,
|
||||
font_family: element.fontFamily ?? null,
|
||||
letter_spacing: element.letterSpacing,
|
||||
line_height: element.lineHeight,
|
||||
fill: element.fill ?? null,
|
||||
locked: element.locked ?? false,
|
||||
initial: element.initial ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
export function normalizeElements(elements: LayoutElement[]): LayoutElement[] {
|
||||
const seen = new Set<string>();
|
||||
return elements
|
||||
.filter((element) => {
|
||||
if (!element.id) {
|
||||
return false;
|
||||
}
|
||||
if (seen.has(element.id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(element.id);
|
||||
return true;
|
||||
})
|
||||
.map(clampElement);
|
||||
}
|
||||
73
resources/js/admin/mobile/qr/utils.ts
Normal file
73
resources/js/admin/mobile/qr/utils.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { EventQrInviteLayout } from '../../api';
|
||||
|
||||
export type BuildInitialTextFieldsParams = {
|
||||
customization: Record<string, unknown> | null | undefined;
|
||||
layoutDefaults: EventQrInviteLayout | null | undefined;
|
||||
eventName?: string | null;
|
||||
};
|
||||
|
||||
export function buildInitialTextFields({
|
||||
customization,
|
||||
layoutDefaults,
|
||||
eventName,
|
||||
}: BuildInitialTextFieldsParams): { headline: string; subtitle: string; description: string; instructions: string[] } {
|
||||
const initialInstructions =
|
||||
Array.isArray(customization?.instructions) && customization.instructions.length
|
||||
? customization.instructions
|
||||
.map((item: unknown) => String(item ?? ''))
|
||||
.filter((item: string) => item.length > 0)
|
||||
: [
|
||||
'QR-Code scannen\nKamera-App öffnen und auf den Code richten.',
|
||||
'Webseite öffnen\nDer Link öffnet direkt das gemeinsame Jubiläumsalbum.',
|
||||
'Fotos hochladen\nZeigt eure Lieblingsmomente oder erfüllt kleine Fotoaufgaben, um besondere Erinnerungen beizusteuern.',
|
||||
];
|
||||
|
||||
return {
|
||||
headline:
|
||||
(typeof customization?.headline === 'string' && customization.headline.length ? customization.headline : null) ??
|
||||
(typeof eventName === 'string' && eventName.length ? eventName : null) ??
|
||||
(typeof layoutDefaults?.name === 'string' ? layoutDefaults.name : '') ??
|
||||
'',
|
||||
subtitle: typeof customization?.subtitle === 'string' ? customization.subtitle : '',
|
||||
description:
|
||||
(typeof customization?.description === 'string' && customization.description.length ? customization.description : null) ??
|
||||
(typeof layoutDefaults?.description === 'string' ? layoutDefaults.description : null) ??
|
||||
'Helft uns, diesen besonderen Tag mit euren schönen Momenten festzuhalten.',
|
||||
instructions: initialInstructions,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLayoutForFormat(
|
||||
format: 'a4-poster' | 'a5-foldable',
|
||||
layouts: EventQrInviteLayout[],
|
||||
): string | null {
|
||||
const formatKey = format === 'a4-poster' ? 'poster-a4' : 'foldable-a5';
|
||||
|
||||
const byHint = layouts.find((layout) => (layout as any)?.format_hint === formatKey);
|
||||
if (byHint?.id) {
|
||||
return byHint.id;
|
||||
}
|
||||
|
||||
const match = layouts.find((layout) => {
|
||||
const paper = (layout.paper || '').toLowerCase();
|
||||
const orientation = (layout.orientation || '').toLowerCase();
|
||||
const panel = (layout.panel_mode || '').toLowerCase();
|
||||
if (format === 'a4-poster') {
|
||||
return paper === 'a4' && orientation === 'portrait' && panel !== 'double-mirror';
|
||||
}
|
||||
|
||||
return paper === 'a4' && orientation === 'landscape' && panel === 'double-mirror';
|
||||
});
|
||||
|
||||
if (match?.id) {
|
||||
return match.id;
|
||||
}
|
||||
|
||||
if (format === 'a5-foldable') {
|
||||
const fallback = layouts.find((layout) => (layout.id || '').includes('foldable'));
|
||||
|
||||
return fallback?.id ?? layouts[0]?.id ?? null;
|
||||
}
|
||||
|
||||
return layouts[0]?.id ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user