import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import JSZip from 'jszip' import { de } from '../strings/de' import type { Gerbil } from '../api/types' import { listGerbilPhotos, photoSrc, type GerbilPhoto } from '../api/photos' import { generateSaleAd } from '../api/saleAd' import { useApi, useMutation } from '../hooks/useApi' import { formatDate, genderLabel } from '../format/labels' import { traitLabels } from '../format/traits' import FarbschlagImage from './FarbschlagImage' import Charakterbogen from './Charakterbogen' import { useGerbilName } from './breederSuffix' type SaleStatus = 'free' | 'loose' | 'reserved' export interface GroupComposerProps { /** 1-based group number for the heading. */ groupNumber: number animals: Gerbil[] /** Resolve a gerbil's Farbschlag display name (stored ColorVariety or '—'). */ farbschlagOf: (g: Gerbil) => string } /** Group gender label for the heading ("männlich"/"weiblich"/"gemischt"). */ function groupGender(animals: Gerbil[]): string { const g = de.pages.abgabe.listing const genders = new Set(animals.map((a) => a.gender)) if (genders.size === 1 && genders.has('male')) return g.genderMale if (genders.size === 1 && genders.has('female')) return g.genderFemale return g.genderMixed } export default function GroupComposer({ groupNumber, animals, farbschlagOf }: GroupComposerProps) { const t = de.pages.abgabe const gerbilName = useGerbilName() const navigate = useNavigate() const [status, setStatus] = useState('free') const [reservedName, setReservedName] = useState('') const [tagline, setTagline] = useState('') // Per-animal character note, seeded from the persisted characterNote (or notes). const [personality, setPersonality] = useState>(() => Object.fromEntries(animals.map((a) => [a.id, a.characterNote ?? a.notes ?? ''])), ) // Per-animal selected trait keys, seeded from the persisted Charakterbogen. const [traitsByAnimal, setTraitsByAnimal] = useState>(() => Object.fromEntries(animals.map((a) => [a.id, a.characterTraits ?? []])), ) const [selectedPhotos, setSelectedPhotos] = useState>({}) const [hints, setHints] = useState('') const [notice, setNotice] = useState(null) const [aiText, setAiText] = useState(null) const [zipping, setZipping] = useState(false) const animalIds = animals.map((a) => a.id).join(',') // Load photos per animal; tolerate a missing photo endpoint (-> empty). const photoData = useApi( () => Promise.all( animals.map((a) => listGerbilPhotos(a.id) .then((photos) => ({ id: a.id, photos })) .catch(() => ({ id: a.id, photos: [] as GerbilPhoto[] })), ), ), [animalIds], ) const photosById = useMemo(() => { const m = new Map() for (const e of photoData.data ?? []) m.set(e.id, e.photos) return m }, [photoData.data]) const ai = useMutation(generateSaleAd) const statusLine = (() => { if (status === 'loose') return `${t.listing.statusLooseReserved} ${reservedName}`.trim() if (status === 'reserved') return t.listing.statusReserved return t.listing.statusFree })() const listingText = useMemo(() => { const heading = t.listing.headingFor(groupNumber, groupGender(animals)) const lines: string[] = [heading, `${t.listing.status}: ${statusLine}`, ''] if (tagline.trim()) lines.push(tagline.trim(), '') for (const a of animals) { const fs = farbschlagOf(a) const born = a.dateOfBirth ? `, ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : '' lines.push(`${a.name} – ${fs}${born}`) const p = (personality[a.id] ?? '').trim() if (p) lines.push(p) lines.push('') } return lines.join('\n').trimEnd() }, [groupNumber, animals, statusLine, tagline, personality, farbschlagOf, t]) function togglePhoto(photoId: string) { setSelectedPhotos((s) => ({ ...s, [photoId]: !isSelected(photoId) })) } // Default: a photo is selected unless explicitly toggled off. function isSelected(photoId: string): boolean { return selectedPhotos[photoId] ?? true } async function copyText() { setNotice(null) try { await navigator.clipboard.writeText(aiText ?? listingText) setNotice(t.export.copied) } catch { setNotice(t.export.copyFailed) } } async function downloadPhotos() { setNotice(null) const zip = new JSZip() const folder = zip.folder(`gruppe-${groupNumber}`)! let count = 0 for (const a of animals) { const photos = (photosById.get(a.id) ?? []).filter((p) => isSelected(p.id)) let n = 1 for (const p of photos) { try { const blob = await fetch(photoSrc(p)).then((r) => (r.ok ? r.blob() : null)) if (!blob) continue const ext = (p.fileName.split('.').pop() ?? 'jpg').toLowerCase() folder.file(`${slug(a.name)}-${n}.${ext}`, blob) n += 1 count += 1 } catch { /* skip unreachable photo */ } } } if (count === 0) { setNotice(t.export.noPhotosSelected) return } try { setZipping(true) const out = await zip.generateAsync({ type: 'blob' }) triggerDownload(out, `gruppe-${groupNumber}-fotos.zip`) } catch { setNotice(t.export.zipFailed) } finally { setZipping(false) } } async function improveWithAi() { setNotice(null) const result = await ai.run({ animals: animals.map((a) => ({ name: a.name, farbschlag: farbschlagOf(a), dateOfBirth: a.dateOfBirth, notes: a.notes ?? null, traits: traitLabels(traitsByAnimal[a.id] ?? []), characterNote: personality[a.id] ?? null, })), statusLine, hints, }) if (result.ok) setAiText(result.value.text) // On failure (endpoint not configured yet) the notice below shows the German hint. } return (

{t.listing.headingFor(groupNumber, groupGender(animals))}

{status === 'loose' && ( )}
{animals.map((a) => (
{gerbilName(a) || a.name} {farbschlagOf(a)} · {genderLabel(a.gender)} {a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}
setTraitsByAnimal((s) => ({ ...s, [a.id]: tr }))} onNoteChange={(v) => setPersonality((s) => ({ ...s, [a.id]: v }))} />
{(photosById.get(a.id) ?? []).length === 0 ? ( {t.listing.noPhotos} ) : ( (photosById.get(a.id) ?? []).map((p) => ( )) )}
))}
{aiText ?? listingText}
{ai.error &&
{t.ai.notConfigured}
} {notice &&
{notice}
}
) } function slug(name: string): string { return name .toLowerCase() .replace(/[äöü]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue' })[c] ?? c) .replace(/ß/g, 'ss') .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '') } function triggerDownload(blob: Blob, filename: string) { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(url) }