and a reduced‑motion guard. Haptics now honor the toggle and still fall back gracefully on iOS (switch disabled when
navigator.vibrate isn’t available).
What changed
- Haptics preference storage + gating: resources/js/guest/lib/haptics.ts
- Preference hook: resources/js/guest/hooks/useHapticsPreference.ts
- Settings UI toggle in sheet + page: resources/js/guest/components/settings-sheet.tsx, resources/js/guest/pages/
SettingsPage.tsx
- i18n labels: resources/js/guest/i18n/messages.ts
- Tests: resources/js/guest/lib/__tests__/haptics.test.ts
171 lines
5.9 KiB
TypeScript
171 lines
5.9 KiB
TypeScript
import { useCallback, useState } from 'react';
|
||
import { compressPhoto, formatBytes } from '../lib/image';
|
||
import { uploadPhoto, type UploadError } from '../services/photosApi';
|
||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
|
||
import { notify } from '../queue/notify';
|
||
import { useTranslation } from '../i18n/useTranslation';
|
||
import { isGuestDemoModeEnabled } from '../demo/demoMode';
|
||
import { useEventData } from './useEventData';
|
||
import { triggerHaptic } from '../lib/haptics';
|
||
|
||
type DirectUploadResult = {
|
||
success: boolean;
|
||
photoId?: number;
|
||
warning?: string | null;
|
||
error?: string | null;
|
||
dialog?: UploadErrorDialog | null;
|
||
};
|
||
|
||
type UseDirectUploadOptions = {
|
||
eventToken: string;
|
||
taskId?: number | null;
|
||
emotionSlug?: string;
|
||
onCompleted?: (photoId: number) => void;
|
||
};
|
||
|
||
export function useDirectUpload({ eventToken, taskId, emotionSlug, onCompleted }: UseDirectUploadOptions) {
|
||
const { name } = useGuestIdentity();
|
||
const { markCompleted } = useGuestTaskProgress(eventToken);
|
||
const { event } = useEventData();
|
||
const { t } = useTranslation();
|
||
const [uploading, setUploading] = useState(false);
|
||
const [progress, setProgress] = useState(0);
|
||
const [warning, setWarning] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
|
||
const [canUpload, setCanUpload] = useState(true);
|
||
|
||
const reset = useCallback(() => {
|
||
setProgress(0);
|
||
setWarning(null);
|
||
setError(null);
|
||
setErrorDialog(null);
|
||
}, []);
|
||
|
||
const preparePhoto = useCallback(async (file: File) => {
|
||
reset();
|
||
let prepared = file;
|
||
try {
|
||
prepared = await compressPhoto(file, {
|
||
maxEdge: 2400,
|
||
targetBytes: 4_000_000,
|
||
qualityStart: 0.82,
|
||
});
|
||
if (prepared.size < file.size - 50_000) {
|
||
const saved = formatBytes(file.size - prepared.size);
|
||
setWarning(`Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: ${saved}`);
|
||
}
|
||
} catch (err) {
|
||
console.warn('Direct upload: optimization failed, using original', err);
|
||
setWarning('Optimierung nicht möglich – wir laden das Original hoch.');
|
||
}
|
||
|
||
if (prepared.size > 12_000_000) {
|
||
setError('Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.');
|
||
return { ok: false as const };
|
||
}
|
||
|
||
return { ok: true as const, prepared };
|
||
}, [reset]);
|
||
|
||
const upload = useCallback(
|
||
async (file: File): Promise<DirectUploadResult> => {
|
||
if (!canUpload || uploading) return { success: false, warning, error };
|
||
if (isGuestDemoModeEnabled() || event?.demo_read_only) {
|
||
const demoMessage = t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.');
|
||
setError(demoMessage);
|
||
setWarning(null);
|
||
notify(demoMessage, 'error');
|
||
return { success: false, warning, error: demoMessage };
|
||
}
|
||
const preparedResult = await preparePhoto(file);
|
||
if (!preparedResult.ok) {
|
||
return { success: false, warning, error };
|
||
}
|
||
|
||
const prepared = preparedResult.prepared;
|
||
setUploading(true);
|
||
setProgress(2);
|
||
setError(null);
|
||
setErrorDialog(null);
|
||
|
||
try {
|
||
const photoId = await uploadPhoto(eventToken, prepared, taskId ?? undefined, emotionSlug || undefined, {
|
||
maxRetries: 2,
|
||
guestName: name || undefined,
|
||
onProgress: (percent) => {
|
||
setProgress(Math.max(10, Math.min(98, percent)));
|
||
},
|
||
onRetry: (attempt) => {
|
||
setWarning(`Verbindung holperig – neuer Versuch (${attempt}).`);
|
||
},
|
||
});
|
||
|
||
setProgress(100);
|
||
if (taskId) {
|
||
markCompleted(taskId);
|
||
}
|
||
triggerHaptic('success');
|
||
|
||
try {
|
||
const raw = localStorage.getItem('my-photo-ids');
|
||
const arr: number[] = raw ? JSON.parse(raw) : [];
|
||
if (photoId && !arr.includes(photoId)) {
|
||
localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...arr]));
|
||
}
|
||
} catch (persistErr) {
|
||
console.warn('Direct upload: persist my-photo-ids failed', persistErr);
|
||
}
|
||
|
||
onCompleted?.(photoId);
|
||
return { success: true, photoId, warning };
|
||
} catch (err) {
|
||
console.error('Direct upload failed', err);
|
||
triggerHaptic('error');
|
||
const uploadErr = err as UploadError;
|
||
const meta = uploadErr.meta as Record<string, unknown> | undefined;
|
||
const dialog = resolveUploadErrorDialog(uploadErr.code, meta, (v: string) => v);
|
||
setErrorDialog(dialog);
|
||
setError(dialog?.description ?? uploadErr.message ?? 'Upload fehlgeschlagen.');
|
||
setWarning(null);
|
||
|
||
if (uploadErr.code === 'demo_read_only') {
|
||
notify(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.'), 'error');
|
||
}
|
||
|
||
if (
|
||
uploadErr.code === 'photo_limit_exceeded'
|
||
|| uploadErr.code === 'upload_device_limit'
|
||
|| uploadErr.code === 'event_package_missing'
|
||
|| uploadErr.code === 'event_not_found'
|
||
|| uploadErr.code === 'gallery_expired'
|
||
) {
|
||
setCanUpload(false);
|
||
}
|
||
|
||
if (uploadErr.status === 422 || uploadErr.code === 'validation_error') {
|
||
setWarning('Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.');
|
||
}
|
||
|
||
return { success: false, warning, error: dialog?.description ?? uploadErr.message, dialog };
|
||
} finally {
|
||
setUploading(false);
|
||
setProgress((p) => (p === 100 ? p : 0));
|
||
}
|
||
},
|
||
[canUpload, emotionSlug, eventToken, markCompleted, name, preparePhoto, taskId, uploading, warning, onCompleted]
|
||
);
|
||
|
||
return {
|
||
upload,
|
||
uploading,
|
||
progress,
|
||
warning,
|
||
error,
|
||
errorDialog,
|
||
reset,
|
||
};
|
||
}
|