legal documents improved, gäste-pwa uploads optimiert: client-side compression/resize.

This commit is contained in:
Codex Agent
2025-11-20 22:09:11 +01:00
parent b8cd32c030
commit e0127e7f39
8 changed files with 197 additions and 76 deletions

View File

@@ -2353,7 +2353,7 @@ class EventPublicController extends BaseController
} }
$validated = $request->validate([ $validated = $request->validate([
'photo' => ['required', 'image', 'max:6144'], // 6 MB 'photo' => ['required', 'image', 'max:12288'], // 12 MB
'emotion_id' => ['nullable', 'integer'], 'emotion_id' => ['nullable', 'integer'],
'emotion_slug' => ['nullable', 'string'], 'emotion_slug' => ['nullable', 'string'],
'task_id' => ['nullable', 'integer'], 'task_id' => ['nullable', 'integer'],

View File

@@ -8,7 +8,7 @@ Schweriner Str. 15
19306 Neustadt-Glewe 19306 Neustadt-Glewe
Mobil 0173 / 9266802 Mobil 0173 / 9266802
W-IdNr. DE 428754098 W-IdNr. DE 428754098
E-Mail: [Kontakt siehe Impressum](https://fotospiel.app/impressum) E-Mail: [Kontakt siehe Impressum](/de/impressum)
--- ---
@@ -57,11 +57,10 @@ Der Anbieter verwendet Inhalte ausschließlich zur technischen Bereitstellung (S
## 7. Preise und Zahlung ## 7. Preise und Zahlung
1. Es gelten die auf der Website veröffentlichten Preise zum Zeitpunkt der Buchung. 1. Es gelten die auf der Website veröffentlichten Preise zum Zeitpunkt der Buchung.
2. Alle Preise verstehen sich einschließlich gesetzlicher Umsatzsteuer. 2. Alle Preise verstehen sich einschließlich gesetzlicher Umsatzsteuer.
3. Die Zahlung erfolgt im Voraus über **Paddle** oder **Stripe Checkout** (Kreditkarte, Apple Pay, Google Pay u. a.). 3. Die Zahlung erfolgt im Voraus über **Paddle** (z. B. Kreditkarte, Apple Pay, Google Pay).
4. Bei Nutzung dieser Dienste gelten zusätzlich die AGB und Datenschutzhinweise der jeweiligen Anbieter: 4. Bei Nutzung dieses Dienstes gelten zusätzlich die AGB und Datenschutzhinweise des Anbieters:
Paddle (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg Paddle.com Market Ltd., London, Vereinigtes Königreich
Stripe Payments Europe Ltd., Dublin, Irland 5. Der Anbieter erhält von diesem Dienst nur die für die Abwicklung erforderlichen Zahlungs- und Statusinformationen.
5. Der Anbieter erhält von diesen Diensten nur Zahlungs- und Statusinformationen zur Abwicklung.
6. Rechnungen werden elektronisch bereitgestellt. 6. Rechnungen werden elektronisch bereitgestellt.
--- ---
@@ -82,7 +81,7 @@ Ein konkreter Verfügbarkeitsgrad wird nicht geschuldet.
--- ---
## 10. Datenschutz ## 10. Datenschutz
1. Personenbezogene Daten werden gemäß der **Datenschutzerklärung** verarbeitet ([https://fotospiel.app/datenschutz](https://fotospiel.app/datenschutz)). 1. Personenbezogene Daten werden gemäß der **Datenschutzerklärung** verarbeitet ([/de/datenschutz](/de/datenschutz)).
2. Der Betrieb erfolgt auf Servern der **Hetzner Online GmbH**, mit der ein Auftragsverarbeitungsvertrag besteht. 2. Der Betrieb erfolgt auf Servern der **Hetzner Online GmbH**, mit der ein Auftragsverarbeitungsvertrag besteht.
3. Analysen erfolgen mit **Matomo**, nur mit technisch notwendigen Cookies. 3. Analysen erfolgen mit **Matomo**, nur mit technisch notwendigen Cookies.

View File

@@ -8,7 +8,7 @@ Schweriner Str. 15
19306 Neustadt-Glewe, Germany 19306 Neustadt-Glewe, Germany
Mobile +49 173 9266802 Mobile +49 173 9266802
Business ID: DE 428754098 Business ID: DE 428754098
Email: [Contact via imprint](https://fotospiel.app/impressum) Email: [Contact via imprint](/en/imprint)
--- ---
@@ -57,11 +57,10 @@ The Provider uses such content solely for technical purposes (storage, display,
## 7. Prices and Payment ## 7. Prices and Payment
1. Prices valid at the time of booking apply. 1. Prices valid at the time of booking apply.
2. All prices include VAT, unless otherwise stated. 2. All prices include VAT, unless otherwise stated.
3. Payment is made in advance via **Paddle** or **Stripe Checkout** (credit card, Apple Pay, Google Pay, etc.). 3. Payment is made in advance via **Paddle** (e.g., credit card, Apple Pay, Google Pay).
4. The payment process is governed by the respective providers terms: 4. The payment process is governed by the providers terms:
Paddle (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg Paddle.com Market Ltd., London, United Kingdom
Stripe Payments Europe Ltd., Dublin, Ireland 5. The Provider only receives the transaction and payment status data necessary for processing.
5. The Provider only receives transaction and payment status data necessary for processing.
6. Invoices are issued electronically. 6. Invoices are issued electronically.
--- ---
@@ -82,7 +81,7 @@ No specific uptime is guaranteed.
--- ---
## 10. Data Protection ## 10. Data Protection
1. Personal data is processed in accordance with the **Privacy Policy** ([https://fotospiel.app/datenschutz](https://fotospiel.app/datenschutz)). 1. Personal data is processed in accordance with the **Privacy Policy** ([/en/privacy](/en/privacy)).
2. Operation takes place on servers of **Hetzner Online GmbH** under a data processing agreement (Art. 28 GDPR). 2. Operation takes place on servers of **Hetzner Online GmbH** under a data processing agreement (Art. 28 GDPR).
3. Web analytics are conducted via **Matomo**, using only technically necessary cookies. 3. Web analytics are conducted via **Matomo**, using only technically necessary cookies.

View File

@@ -10,7 +10,7 @@ Schweriner Str. 15
Deutschland Deutschland
E-Mail: info@fotospiel.app E-Mail: info@fotospiel.app
Website: [https://fotospiel.app](https://fotospiel.app) Website: [/de/](/de/)
--- ---
@@ -21,9 +21,9 @@ Die Nutzung der Fotospiel App ist grundsätzlich nur mit den personenbezogenen D
--- ---
## 3. Arten der verarbeiteten Daten ## 3. Arten der verarbeiteten Daten
- Veranstalterdaten: Name, E-Mail-Adresse, Zahlungsinformationen (über Paddle/Stripe), Eventdaten (Titel, Datum, Aufgaben, Bilder) - Veranstalterdaten: Name, E-Mail-Adresse, Zahlungsinformationen (über Paddle), Eventdaten (Titel, Datum, Aufgaben, Bilder)
- Nutzerdaten (Gäste): hochgeladene Fotos, Anzeigename (frei wählbar), Reaktionen/Likes - Nutzerdaten (Gäste): hochgeladene Fotos, Anzeigename (frei wählbar), Reaktionen/Likes
- Technische Daten: IP-Adresse, Browsertyp, Zeitstempel, Geräteinformationen - Technische Daten: IP-Adresse, Browsertyp, Zeitstempel, Geräteinformationen, anonyme Sitzungskennung (session_id)
- Kommunikationsdaten: Inhalte von Kontaktanfragen über das Formular oder per E-Mail - Kommunikationsdaten: Inhalte von Kontaktanfragen über das Formular oder per E-Mail
--- ---
@@ -33,7 +33,7 @@ Die Nutzung der Fotospiel App ist grundsätzlich nur mit den personenbezogenen D
|------------------------|----------------|---------------| |------------------------|----------------|---------------|
| Bereitstellung der App und Durchführung von Veranstaltungen | Art. 6 Abs. 1 lit. b DSGVO | Nutzung der App durch Veranstalter und Gäste | | Bereitstellung der App und Durchführung von Veranstaltungen | Art. 6 Abs. 1 lit. b DSGVO | Nutzung der App durch Veranstalter und Gäste |
| Speicherung und Anzeige von Fotos innerhalb des Events | Art. 6 Abs. 1 lit. b DSGVO | Durchführung der Fotospiel-Funktionalität | | Speicherung und Anzeige von Fotos innerhalb des Events | Art. 6 Abs. 1 lit. b DSGVO | Durchführung der Fotospiel-Funktionalität |
| Abrechnung und Zahlungsabwicklung | Art. 6 Abs. 1 lit. b, lit. c DSGVO | Nutzung der Dienste von Paddle und Stripe | | Abrechnung und Zahlungsabwicklung | Art. 6 Abs. 1 lit. b, lit. c DSGVO | Nutzung der Dienste von Paddle |
| Webanalyse über Matomo (selbst gehostet) | Art. 6 Abs. 1 lit. f DSGVO | Statistische Auswertung zur Verbesserung der App | | Webanalyse über Matomo (selbst gehostet) | Art. 6 Abs. 1 lit. f DSGVO | Statistische Auswertung zur Verbesserung der App |
| Sicherheit, Server-Logs | Art. 6 Abs. 1 lit. f DSGVO | Sicherstellung des Betriebs, Fehleranalyse | | Sicherheit, Server-Logs | Art. 6 Abs. 1 lit. f DSGVO | Sicherstellung des Betriebs, Fehleranalyse |
| Beantwortung von Kontaktanfragen | Art. 6 Abs. 1 lit. f oder lit. b DSGVO | Kommunikation mit Nutzern und Interessenten | | Beantwortung von Kontaktanfragen | Art. 6 Abs. 1 lit. f oder lit. b DSGVO | Kommunikation mit Nutzern und Interessenten |
@@ -48,14 +48,13 @@ Die Verarbeitung erfolgt ausschließlich innerhalb der EU.
--- ---
## 6. Zahlungsabwicklung ## 6. Zahlungsabwicklung
Die Zahlungsabwicklung erfolgt über **Paddle (Europe) S.à r.l. et Cie, S.C.A.** und **Stripe Payments Europe, Ltd.** Die Zahlungsabwicklung erfolgt über **Paddle.com Market Ltd.**
Bei der Zahlung werden personenbezogene Daten an diese Dienstleister übermittelt. Bei der Zahlung werden personenbezogene Daten an diesen Dienstleister übermittelt.
Wir speichern keine Zahlungs- oder Kreditkartendaten. Wir speichern keine Zahlungs- oder Kreditkartendaten.
Rechtsgrundlage: Art. 6 Abs. 1 lit. b und lit. c DSGVO. Rechtsgrundlage: Art. 6 Abs. 1 lit. b und lit. c DSGVO.
Datenschutzhinweise der Anbieter: Datenschutzhinweise der Anbieter:
- Paddle: https://www.paypal.com/de/webapps/mpp/ua/privacy-full - Paddle: https://www.paddle.com/legal/privacy
- Stripe: https://stripe.com/de/privacy
--- ---
@@ -63,7 +62,8 @@ Datenschutzhinweise der Anbieter:
Wir verwenden **Matomo** (lokal gehostet) zur Analyse des Nutzerverhaltens. Wir verwenden **Matomo** (lokal gehostet) zur Analyse des Nutzerverhaltens.
Es werden keine Daten an Dritte übermittelt. Es werden keine Daten an Dritte übermittelt.
IP-Adressen werden anonymisiert gespeichert. IP-Adressen werden anonymisiert gespeichert.
Nur technisch notwendige Cookies werden gesetzt. Im Rahmen der Gästebereiche der App wird eine anonyme Sitzungskennung (**session_id**) verwendet, die in einem technisch notwendigen Cookie bzw. im lokalen Speicher (Local Storage) des Browsers gespeichert wird, um Uploads, Likes und Aufgaben einem Gerät bzw. einer Sitzung zuzuordnen. Diese Kennung enthält keine Klardaten wie Namen oder E-Mail-Adressen und verliert spätestens mit Ablauf der Event- bzw. Galeriedauer ihre Gültigkeit.
Es werden ausschließlich technisch notwendige Cookies gesetzt.
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO.
--- ---
@@ -88,7 +88,7 @@ Eine Einwilligung ist nicht erforderlich.
## 10. Weitergabe an Dritte ## 10. Weitergabe an Dritte
Eine Weitergabe erfolgt nur an: Eine Weitergabe erfolgt nur an:
- Zahlungsdienstleister (Paddle, Stripe) - Zahlungsdienstleister (Paddle)
- Hoster (Hetzner) - Hoster (Hetzner)
- Gesetzlich erforderliche Stellen (z. B. Finanzbehörden) - Gesetzlich erforderliche Stellen (z. B. Finanzbehörden)
@@ -121,4 +121,4 @@ Wir setzen technische und organisatorische Maßnahmen zur Sicherung Ihrer Daten
## 14. Änderungen dieser Datenschutzerklärung ## 14. Änderungen dieser Datenschutzerklärung
Wir behalten uns vor, diese Datenschutzerklärung anzupassen. Wir behalten uns vor, diese Datenschutzerklärung anzupassen.
Die jeweils aktuelle Fassung ist unter [https://fotospiel.app/datenschutz](https://fotospiel.app/datenschutz) abrufbar. Die jeweils aktuelle Fassung ist unter [/de/datenschutz](/de/datenschutz) abrufbar.

View File

@@ -10,7 +10,7 @@ Schweriner Str. 15
Germany Germany
Email: info@fotospiel.app Email: info@fotospiel.app
Website: [https://fotospiel.app](https://fotospiel.app) Website: [/en/](/en/)
--- ---
@@ -21,9 +21,9 @@ Use of the Fotospiel App requires only the personal data necessary to host and p
--- ---
## 3. Types of Data Processed ## 3. Types of Data Processed
- Organizer data: name, email address, payment information (via Paddle/Stripe), event details (title, date, photo tasks, photos) - Organizer data: name, email address, payment information (via Paddle), event details (title, date, photo tasks, photos)
- Guest data: uploaded photos, display name (optional), likes/reactions - Guest data: uploaded photos, display name (optional), likes/reactions
- Technical data: IP address, browser type, timestamp, device information - Technical data: IP address, browser type, timestamp, device information, anonymous session identifier (session_id)
- Communication data: messages sent via contact form or email - Communication data: messages sent via contact form or email
--- ---
@@ -33,7 +33,7 @@ Use of the Fotospiel App requires only the personal data necessary to host and p
|----------|--------------|-------------| |----------|--------------|-------------|
| Providing the app and hosting events | Art. 6(1)(b) GDPR | Contract performance | | Providing the app and hosting events | Art. 6(1)(b) GDPR | Contract performance |
| Storing and displaying photos | Art. 6(1)(b) GDPR | Core feature of the app | | Storing and displaying photos | Art. 6(1)(b) GDPR | Core feature of the app |
| Payment processing and invoicing | Art. 6(1)(b), (c) GDPR | Use of Paddle and Stripe services | | Payment processing and invoicing | Art. 6(1)(b), (c) GDPR | Use of Paddle services |
| Web analytics via Matomo | Art. 6(1)(f) GDPR | Statistical analysis to improve the app | | Web analytics via Matomo | Art. 6(1)(f) GDPR | Statistical analysis to improve the app |
| Server logs and security | Art. 6(1)(f) GDPR | Ensuring system security | | Server logs and security | Art. 6(1)(f) GDPR | Ensuring system security |
| Responding to inquiries | Art. 6(1)(f) or (b) GDPR | Communication with users | | Responding to inquiries | Art. 6(1)(f) or (b) GDPR | Communication with users |
@@ -48,13 +48,12 @@ All processing takes place within the EU.
--- ---
## 6. Payment Processing ## 6. Payment Processing
Payments are handled by **Paddle (Europe) S.à r.l. et Cie, S.C.A.** and **Stripe Payments Europe, Ltd.** Payments are handled by **Paddle.com Market Ltd.**
We do not store payment or credit card data. We do not store payment or credit card data.
Legal basis: Art. 6(1)(b) and (c) GDPR. Legal basis: Art. 6(1)(b) and (c) GDPR.
Privacy policies: Privacy policies:
- Paddle: https://www.paypal.com/de/webapps/mpp/ua/privacy-full - Paddle: https://www.paddle.com/legal/privacy
- Stripe: https://stripe.com/de/privacy
--- ---
@@ -62,6 +61,7 @@ Privacy policies:
We use **Matomo** (self-hosted) for anonymous usage analysis. We use **Matomo** (self-hosted) for anonymous usage analysis.
No data is shared with third parties. No data is shared with third parties.
IP addresses are anonymized. IP addresses are anonymized.
In the guest areas of the app, an anonymous session identifier (**session_id**) is used and stored in a technically necessary cookie or in the browsers local storage to associate uploads, likes, and tasks with a device or session. This identifier does not contain clear data such as names or email addresses and becomes invalid at the latest when the event or gallery storage period ends.
Only technically necessary cookies are used. Only technically necessary cookies are used.
Legal basis: Art. 6(1)(f) GDPR. Legal basis: Art. 6(1)(f) GDPR.
@@ -87,7 +87,7 @@ No consent is required.
## 10. Data Disclosure ## 10. Data Disclosure
Data is only shared with: Data is only shared with:
- Payment providers (Paddle, Stripe) - Payment providers (Paddle)
- Hosting provider (Hetzner) - Hosting provider (Hetzner)
- Public authorities when legally required - Public authorities when legally required
@@ -120,4 +120,4 @@ We apply appropriate technical and organizational measures to secure your data,
## 14. Changes to this Privacy Policy ## 14. Changes to this Privacy Policy
We may update this Privacy Policy to reflect legal or functional changes. We may update this Privacy Policy to reflect legal or functional changes.
The current version is always available at [https://fotospiel.app/privacy](https://fotospiel.app/privacy). The current version is always available at [/en/privacy](/en/privacy).

View File

@@ -32,6 +32,7 @@ import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries'; import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog'; import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
import { useEventStats } from '../context/EventStatsContext'; import { useEventStats } from '../context/EventStatsContext';
import { compressPhoto, formatBytes } from '../lib/image';
interface Task { interface Task {
id: number; id: number;
@@ -201,6 +202,8 @@ const [canUpload, setCanUpload] = useState(true);
let active = true; let active = true;
async function loadTask() { async function loadTask() {
if (taskId === null) return;
const currentTaskId = taskId; const currentTaskId = taskId;
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${currentTaskId}`); const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${currentTaskId}`);
const fallbackDescription = t('upload.taskInfo.fallbackDescription'); const fallbackDescription = t('upload.taskInfo.fallbackDescription');
@@ -553,19 +556,57 @@ const [canUpload, setCanUpload] = useState(true);
const handleUsePhoto = useCallback(async () => { const handleUsePhoto = useCallback(async () => {
if (!eventKey || !reviewPhoto || !task || !canUpload) return; if (!eventKey || !reviewPhoto || !task || !canUpload) return;
setMode('uploading'); setMode('uploading');
setUploadProgress(5); setUploadProgress(2);
setUploadError(null); setUploadError(null);
setUploadWarning(null);
setStatusMessage(t('upload.status.preparing')); setStatusMessage(t('upload.status.preparing'));
if (uploadProgressTimerRef.current) { if (uploadProgressTimerRef.current) {
window.clearInterval(uploadProgressTimerRef.current); window.clearInterval(uploadProgressTimerRef.current);
uploadProgressTimerRef.current = null;
} }
uploadProgressTimerRef.current = window.setInterval(() => {
setUploadProgress((prev) => (prev < 90 ? prev + 5 : prev)); const maxEdge = 2400;
}, 400); const targetBytes = 4_000_000;
let fileForUpload = reviewPhoto.file;
try { try {
const photoId = await uploadPhoto(eventKey, reviewPhoto.file, task.id, emotionSlug || undefined); setStatusMessage(t('upload.status.optimizing', 'Foto wird optimiert…'));
const optimized = await compressPhoto(reviewPhoto.file, {
maxEdge,
targetBytes,
qualityStart: 0.82,
});
fileForUpload = optimized;
setUploadProgress(10);
if (optimized.size < reviewPhoto.file.size - 50_000) {
const saved = formatBytes(reviewPhoto.file.size - optimized.size);
setUploadWarning(
t('upload.optimizedNotice', 'Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: {saved}')
.replace('{saved}', saved)
);
}
} catch (e) {
console.warn('Image optimization failed, uploading original', e);
setUploadWarning(t('upload.optimizedFallback', 'Optimierung nicht möglich wir laden das Original hoch.'));
}
try {
const photoId = await uploadPhoto(eventKey, fileForUpload, task.id, emotionSlug || undefined, {
maxRetries: 2,
onProgress: (percent) => {
setUploadProgress(Math.max(15, Math.min(98, percent)));
setStatusMessage(t('upload.status.uploading'));
},
onRetry: (attempt) => {
setUploadWarning(
t('upload.retrying', 'Verbindung holperig neuer Versuch ({attempt}).')
.replace('{attempt}', `${attempt}`)
);
},
});
setUploadProgress(100); setUploadProgress(100);
setStatusMessage(t('upload.status.completed')); setStatusMessage(t('upload.status.completed'));
markCompleted(task.id); markCompleted(task.id);
@@ -580,6 +621,12 @@ const [canUpload, setCanUpload] = useState(true);
setErrorDialog(dialog); setErrorDialog(dialog);
setUploadError(dialog.description); setUploadError(dialog.description);
if (uploadErr.status === 422 || uploadErr.code === 'validation_error') {
setUploadWarning(
t('upload.errors.tooLargeHint', 'Das Foto war zu groß. Bitte erneut versuchen wir verkleinern es automatisch.')
);
}
if ( if (
uploadErr.code === 'photo_limit_exceeded' uploadErr.code === 'photo_limit_exceeded'
|| uploadErr.code === 'upload_device_limit' || uploadErr.code === 'upload_device_limit'
@@ -592,10 +639,6 @@ const [canUpload, setCanUpload] = useState(true);
setMode('review'); setMode('review');
} finally { } finally {
if (uploadProgressTimerRef.current) {
window.clearInterval(uploadProgressTimerRef.current);
uploadProgressTimerRef.current = null;
}
setStatusMessage(''); setStatusMessage('');
} }
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t]); }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t]);

View File

@@ -90,49 +90,117 @@ export async function likePhoto(id: number): Promise<number> {
return json.likes_count ?? json.data?.likes_count ?? 0; return json.likes_count ?? json.data?.likes_count ?? 0;
} }
export async function uploadPhoto(eventToken: string, file: File, taskId?: number, emotionSlug?: string): Promise<number> { type UploadOptions = {
onProgress?: (percent: number) => void;
signal?: AbortSignal;
maxRetries?: number;
onRetry?: (attempt: number) => void;
};
export async function uploadPhoto(
eventToken: string,
file: File,
taskId?: number,
emotionSlug?: string,
options: UploadOptions = {}
): Promise<number> {
const formData = new FormData(); const formData = new FormData();
formData.append('photo', file, `photo-${Date.now()}.jpg`); formData.append('photo', file, file.name || `photo-${Date.now()}.jpg`);
if (taskId) formData.append('task_id', taskId.toString()); if (taskId) formData.append('task_id', taskId.toString());
if (emotionSlug) formData.append('emotion_slug', emotionSlug); if (emotionSlug) formData.append('emotion_slug', emotionSlug);
formData.append('device_id', getDeviceId()); formData.append('device_id', getDeviceId());
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/upload`, { const maxRetries = options.maxRetries ?? 2;
method: 'POST', const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
credentials: 'include', const headers = getCsrfHeaders();
body: formData,
// Don't set Content-Type for FormData - let browser handle it with boundary
});
if (!res.ok) { const attemptUpload = (attempt: number): Promise<any> =>
let payload: any = null; new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.withCredentials = true;
xhr.responseType = 'json';
Object.entries(headers).forEach(([key, value]) => {
xhr.setRequestHeader(key, value);
});
if (options.signal) {
const onAbort = () => xhr.abort();
options.signal.addEventListener('abort', onAbort, { once: true });
}
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && options.onProgress) {
const percent = Math.min(99, Math.round((event.loaded / event.total) * 100));
options.onProgress(percent);
}
};
xhr.onload = () => {
const status = xhr.status;
const payload = xhr.response ?? null;
if (status >= 200 && status < 300) {
resolve(payload);
return;
}
const error: UploadError = new Error(
payload?.error?.message ?? `Upload failed: ${status}`
);
error.code = payload?.error?.code ?? (status === 0 ? 'network_error' : 'upload_failed');
error.status = status;
if (payload?.error?.meta) {
error.meta = payload.error.meta as Record<string, unknown>;
}
reject(error);
};
xhr.onerror = () => {
const error: UploadError = new Error('Network error during upload');
error.code = 'network_error';
reject(error);
};
xhr.ontimeout = () => {
const error: UploadError = new Error('Upload timed out');
error.code = 'timeout';
reject(error);
};
xhr.send(formData);
});
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
payload = await res.clone().json(); const json = await attemptUpload(attempt + 1);
} catch {} return json?.photo_id ?? json?.id ?? json?.data?.id ?? 0;
} catch (error) {
const err = error as UploadError;
if (res.status === 419) { if (attempt < maxRetries && (err.code === 'network_error' || (err.status ?? 0) >= 500)) {
const csrfError: UploadError = new Error( options.onRetry?.(attempt + 1);
'CSRF token mismatch during upload. Please refresh the page and try again.' const delay = 300 * (attempt + 1);
); await new Promise((resolve) => setTimeout(resolve, delay));
csrfError.code = 'csrf_mismatch'; continue;
csrfError.status = res.status; }
throw csrfError;
// Map CSRF mismatch specifically for caller handling
if ((err.status ?? 0) === 419) {
err.code = 'csrf_mismatch';
}
// Flag common validation failure for file size/validation
if ((err.status ?? 0) === 422 && !err.code) {
err.code = 'validation_error';
}
throw err;
} }
const error: UploadError = new Error(
payload?.error?.message ?? `Upload failed: ${res.status}`
);
error.code = payload?.error?.code ?? 'upload_failed';
error.status = res.status;
if (payload?.error?.meta) {
error.meta = payload.error.meta as Record<string, unknown>;
}
throw error;
} }
const json = await res.json(); throw new Error('Upload failed after retries');
return json.photo_id ?? json.id ?? json.data?.id ?? 0;
} }
export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> { export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> {

View File

@@ -126,12 +126,24 @@ Route::prefix('{locale}')
Route::get('/impressum', [LegalPageController::class, 'show']) Route::get('/impressum', [LegalPageController::class, 'show'])
->defaults('slug', 'impressum') ->defaults('slug', 'impressum')
->name('impressum'); ->name('impressum');
Route::get('/imprint', [LegalPageController::class, 'show'])
->where('locale', 'en')
->defaults('slug', 'impressum')
->name('imprint');
Route::get('/datenschutz', [LegalPageController::class, 'show']) Route::get('/datenschutz', [LegalPageController::class, 'show'])
->defaults('slug', 'datenschutz') ->defaults('slug', 'datenschutz')
->name('datenschutz'); ->name('datenschutz');
Route::get('/privacy', [LegalPageController::class, 'show'])
->where('locale', 'en')
->defaults('slug', 'datenschutz')
->name('privacy');
Route::get('/agb', [LegalPageController::class, 'show']) Route::get('/agb', [LegalPageController::class, 'show'])
->defaults('slug', 'agb') ->defaults('slug', 'agb')
->name('agb'); ->name('agb');
Route::get('/terms', [LegalPageController::class, 'show'])
->where('locale', 'en')
->defaults('slug', 'agb')
->name('terms');
Route::get('/buy/{packageId}', [MarketingController::class, 'buyPackages']) Route::get('/buy/{packageId}', [MarketingController::class, 'buyPackages'])
->name('buy.packages') ->name('buy.packages')