mobile template api integration

#deploy
This commit is contained in:
martin 2026-01-27 15:18:52 +01:00
parent 7715cbfb6f
commit cf8af35f9f
2 changed files with 157 additions and 46 deletions

View File

@ -0,0 +1,68 @@
import { getWebhookUrl } from '@/lib/config';
export type TemplateUploadResult = {
success: boolean;
message?: string;
[key: string]: any;
};
export type TemplateRecord = {
id: string | number;
name: string;
description?: string;
type?: string;
preview_img?: string;
[key: string]: any;
};
export const uploadTemplateZip = async (apiFetch: Function, file: File): Promise<TemplateUploadResult> => {
const formData = new FormData();
formData.append('file', file);
const response = await apiFetch(`${getWebhookUrl()}/template`, {
method: 'POST',
body: formData,
skipAuthHandling: true,
});
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const raw = await response.json();
const data = Array.isArray(raw) ? raw[0] ?? {} : raw ?? {};
return {
success: data.success !== false,
...data,
};
}
const text = await response.text();
const trimmed = (text || '').trim();
return {
success: true,
message: trimmed || undefined,
};
};
export const fetchTemplates = async (apiFetch: Function): Promise<TemplateRecord[]> => {
const response = await apiFetch(`${getWebhookUrl()}/templates`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
skipAuthHandling: true,
});
const raw = await response.json();
const data: any =
typeof raw === 'string'
? (() => {
try {
return JSON.parse(raw);
} catch {
return raw;
}
})()
: raw;
return Array.isArray(data) ? (data as TemplateRecord[]) : data ? ([data] as TemplateRecord[]) : [];
};

View File

@ -1,10 +1,13 @@
import React, { useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { useApiFetch } from '@/utils/apiFetch';
import { fetchTemplates, uploadTemplateZip } from '@/features/event/api/templatesService';
import { toast } from 'sonner';
type Props = { type Props = {
mediaUrl: string; mediaUrl: string;
@ -20,6 +23,7 @@ type PromotionTemplate = {
id: string; id: string;
name: string; name: string;
description: string; description: string;
type?: string;
previewUrl?: string; previewUrl?: string;
}; };
@ -31,58 +35,86 @@ const PromotionTemplatesSection: React.FC<Props> = ({
mediaPreview, mediaPreview,
}) => { }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const templates: PromotionTemplate[] = useMemo( const apiFetch = useApiFetch();
() => [ const [allTemplates, setAllTemplates] = useState<PromotionTemplate[]>([]);
{ const [loadingTemplates, setLoadingTemplates] = useState(false);
id: 'tpl_event_minimal',
name: 'Event Minimal',
description: 'Cleanes Event-Template mit Fokus auf Titel + Datum.',
previewUrl: '/images/templates/event_minimal_preview.png',
},
{
id: 'tpl_event_bold',
name: 'Event Bold',
description: 'Große Typo, geeignet für starke Keyvisuals.',
previewUrl: '/images/templates/event_bold_preview.png',
},
{
id: 'tpl_lineup_grid',
name: 'Lineup Grid',
description: 'Lineup als Grid-Layout, gut für mehrere Artists.',
previewUrl: '/images/templates/lineup_grid_preview.png',
},
{
id: 'tpl_artist_focus',
name: 'Artist Focus',
description: 'Portrait/Keyvisual im Fokus, Name + Slot unten.',
previewUrl: '/images/templates/artist_focus_preview.png',
},
{
id: 'tpl_artist_story',
name: 'Artist Story',
description: 'Story-Format mit kurzem Teaser + Social Handles.',
previewUrl: '/images/templates/artist_story_preview.png',
},
],
[]
);
const [eventPromoTemplateId, setEventPromoTemplateId] = useState<string>(templates[0]?.id ?? ''); const eventPromoTemplates = useMemo(() => allTemplates.filter((t) => t.type === 'event_promo' || !t.type), [allTemplates]);
const [eventLineupTemplateId, setEventLineupTemplateId] = useState<string>(templates[2]?.id ?? ''); const eventLineupTemplates = useMemo(() => allTemplates.filter((t) => t.type === 'event_lineup'), [allTemplates]);
const [artistPromoTemplateId, setArtistPromoTemplateId] = useState<string>(templates[3]?.id ?? ''); const artistPromoTemplates = useMemo(() => allTemplates.filter((t) => t.type === 'artist_promo'), [allTemplates]);
const [eventPromoTemplateId, setEventPromoTemplateId] = useState<string>('');
const [eventLineupTemplateId, setEventLineupTemplateId] = useState<string>('');
const [artistPromoTemplateId, setArtistPromoTemplateId] = useState<string>('');
const importZipInputRef = useRef<HTMLInputElement | null>(null); const importZipInputRef = useRef<HTMLInputElement | null>(null);
const [importingTemplate, setImportingTemplate] = useState(false); const [importingTemplate, setImportingTemplate] = useState(false);
const selectedEventPromo = templates.find((t) => t.id === eventPromoTemplateId) || null; const selectedEventPromo = allTemplates.find((t) => t.id === eventPromoTemplateId) || null;
const selectedEventLineup = templates.find((t) => t.id === eventLineupTemplateId) || null; const selectedEventLineup = allTemplates.find((t) => t.id === eventLineupTemplateId) || null;
const selectedArtistPromo = templates.find((t) => t.id === artistPromoTemplateId) || null; const selectedArtistPromo = allTemplates.find((t) => t.id === artistPromoTemplateId) || null;
const loadTemplates = useCallback(async () => {
setLoadingTemplates(true);
try {
const list = await fetchTemplates(apiFetch);
const mapped: PromotionTemplate[] = list.map((t: any) => ({
id: String(t.id),
name: String(t.name || ''),
description: String(t.description || ''),
type: typeof t.type === 'string' ? t.type : undefined,
previewUrl: typeof t.preview_img === 'string' ? t.preview_img : undefined,
}));
setAllTemplates(mapped);
// Initial defaults if nothing selected yet
if (!eventPromoTemplateId && (mapped.find((x) => x.type === 'event_promo') || mapped[0])) {
const firstEvent = mapped.find((x) => x.type === 'event_promo') || mapped[0];
if (firstEvent?.id) setEventPromoTemplateId(firstEvent.id);
}
if (!eventLineupTemplateId) {
const firstLineup = mapped.find((x) => x.type === 'event_lineup');
if (firstLineup?.id) setEventLineupTemplateId(firstLineup.id);
}
if (!artistPromoTemplateId) {
const firstArtist = mapped.find((x) => x.type === 'artist_promo');
if (firstArtist?.id) setArtistPromoTemplateId(firstArtist.id);
}
} catch (e: any) {
toast.error(e?.message || 'Templates konnten nicht geladen werden');
setAllTemplates([]);
} finally {
setLoadingTemplates(false);
}
}, [apiFetch, artistPromoTemplateId, eventLineupTemplateId, eventPromoTemplateId]);
useEffect(() => {
let cancelled = false;
(async () => {
if (cancelled) return;
await loadTemplates();
})();
return () => {
cancelled = true;
};
}, [loadTemplates]);
const handleImportZip = async (file: File) => { const handleImportZip = async (file: File) => {
setImportingTemplate(true); setImportingTemplate(true);
try { try {
// Placeholder: Upload to backend endpoint will be added later const result = await uploadTemplateZip(apiFetch, file);
await new Promise((r) => setTimeout(r, 400));
if (!result?.success) {
throw new Error(result?.message || 'Template-Import fehlgeschlagen');
}
toast.success(result?.message || 'Template importiert');
await loadTemplates();
} catch (e: any) {
const msg = e?.message || 'Template-Import fehlgeschlagen';
toast.error(msg);
} finally { } finally {
setImportingTemplate(false); setImportingTemplate(false);
} }
@ -93,11 +125,13 @@ const PromotionTemplatesSection: React.FC<Props> = ({
value, value,
onValueChange, onValueChange,
selected, selected,
templates,
}: { }: {
label: string; label: string;
value: string; value: string;
onValueChange: (next: string) => void; onValueChange: (next: string) => void;
selected: PromotionTemplate | null; selected: PromotionTemplate | null;
templates: PromotionTemplate[];
}) => { }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -112,6 +146,7 @@ const PromotionTemplatesSection: React.FC<Props> = ({
variant="outline" variant="outline"
className="w-full justify-between" className="w-full justify-between"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
disabled={loadingTemplates || templates.length === 0}
> >
<span className="truncate">{selected?.name || 'Template wählen'}</span> <span className="truncate">{selected?.name || 'Template wählen'}</span>
<span className="text-muted-foreground">Auswählen</span> <span className="text-muted-foreground">Auswählen</span>
@ -201,6 +236,9 @@ const PromotionTemplatesSection: React.FC<Props> = ({
<div className="mt-1 text-xs text-muted-foreground leading-snug hidden md:block">{selected?.description || ''}</div> <div className="mt-1 text-xs text-muted-foreground leading-snug hidden md:block">{selected?.description || ''}</div>
</div> </div>
</div> </div>
{!loadingTemplates && templates.length === 0 && (
<div className="mt-2 text-xs text-muted-foreground">Keine Templates verfügbar</div>
)}
</div> </div>
</div> </div>
</div> </div>
@ -209,7 +247,10 @@ const PromotionTemplatesSection: React.FC<Props> = ({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Promotion Templates</div> <div className="text-sm font-medium">Promotion Templates</div>
<div className="text-xs text-muted-foreground">{loadingTemplates ? 'Lade…' : ''}</div>
</div>
<div className="space-y-3 rounded-md border p-3"> <div className="space-y-3 rounded-md border p-3">
<div className="text-sm font-medium">Sektion 1 Event-Promotion</div> <div className="text-sm font-medium">Sektion 1 Event-Promotion</div>
@ -219,6 +260,7 @@ const PromotionTemplatesSection: React.FC<Props> = ({
value={eventPromoTemplateId} value={eventPromoTemplateId}
onValueChange={setEventPromoTemplateId} onValueChange={setEventPromoTemplateId}
selected={selectedEventPromo} selected={selectedEventPromo}
templates={eventPromoTemplates}
/> />
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
@ -261,6 +303,7 @@ const PromotionTemplatesSection: React.FC<Props> = ({
value={eventLineupTemplateId} value={eventLineupTemplateId}
onValueChange={setEventLineupTemplateId} onValueChange={setEventLineupTemplateId}
selected={selectedEventLineup} selected={selectedEventLineup}
templates={eventLineupTemplates}
/> />
</div> </div>
@ -271,11 +314,11 @@ const PromotionTemplatesSection: React.FC<Props> = ({
value={artistPromoTemplateId} value={artistPromoTemplateId}
onValueChange={setArtistPromoTemplateId} onValueChange={setArtistPromoTemplateId}
selected={selectedArtistPromo} selected={selectedArtistPromo}
templates={artistPromoTemplates}
/> />
</div> </div>
<div className="flex items-center justify-between gap-3 rounded-md border p-3"> <div className="flex items-center justify-between gap-3 rounded-md border p-3">
{/* <div className="text-sm font-medium">Templates verwalten</div> */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
ref={importZipInputRef} ref={importZipInputRef}