From fa5a1fa3677fdefd0d97bb680694b849429d0240 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 27 Dec 2025 14:00:12 +0100 Subject: [PATCH] =?UTF-8?q?Added=20a=20guest=20haptics=20preference=20and?= =?UTF-8?q?=20surfaced=20it=20in=20both=20the=20settings=20sheet=20and=20/?= =?UTF-8?q?settings,=20with=20safe=20device=20detection=20=20=20and=20a=20?= =?UTF-8?q?reduced=E2=80=91motion=20guard.=20Haptics=20now=20honor=20the?= =?UTF-8?q?=20toggle=20and=20still=20fall=20back=20gracefully=20on=20iOS?= =?UTF-8?q?=20(switch=20disabled=20when=20=20=20navigator.vibrate=20isn?= =?UTF-8?q?=E2=80=99t=20available).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../js/guest/components/PullToRefresh.tsx | 11 ++++ .../js/guest/components/settings-sheet.tsx | 30 +++++++++ resources/js/guest/hooks/useDirectUpload.ts | 3 + .../js/guest/hooks/useHapticsPreference.ts | 19 ++++++ resources/js/guest/i18n/messages.ts | 12 ++++ .../js/guest/lib/__tests__/haptics.test.ts | 63 +++++++++++++++++++ resources/js/guest/lib/haptics.ts | 62 ++++++++++++++++++ resources/js/guest/pages/GalleryPage.tsx | 2 + resources/js/guest/pages/PhotoLightbox.tsx | 2 + resources/js/guest/pages/SettingsPage.tsx | 34 +++++++++- resources/js/guest/pages/TaskPickerPage.tsx | 4 ++ 11 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 resources/js/guest/hooks/useHapticsPreference.ts create mode 100644 resources/js/guest/lib/__tests__/haptics.test.ts create mode 100644 resources/js/guest/lib/haptics.ts diff --git a/resources/js/guest/components/PullToRefresh.tsx b/resources/js/guest/components/PullToRefresh.tsx index db8b443..4c95cdd 100644 --- a/resources/js/guest/components/PullToRefresh.tsx +++ b/resources/js/guest/components/PullToRefresh.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { ArrowDown, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { triggerHaptic } from '../lib/haptics'; const MAX_PULL = 96; const TRIGGER_PULL = 72; @@ -28,6 +29,7 @@ export default function PullToRefresh({ const containerRef = React.useRef(null); const startYRef = React.useRef(null); const pullDistanceRef = React.useRef(0); + const readyRef = React.useRef(false); const [pullDistance, setPullDistance] = React.useState(0); const [dragging, setDragging] = React.useState(false); const [refreshing, setRefreshing] = React.useState(false); @@ -70,6 +72,13 @@ export default function PullToRefresh({ event.preventDefault(); const next = Math.min(MAX_PULL, delta * DAMPING); updatePull(next); + const isReady = next >= TRIGGER_PULL; + if (isReady && !readyRef.current) { + readyRef.current = true; + triggerHaptic('selection'); + } else if (!isReady && readyRef.current) { + readyRef.current = false; + } }; const handleEnd = async () => { @@ -78,8 +87,10 @@ export default function PullToRefresh({ } startYRef.current = null; setDragging(false); + readyRef.current = false; if (pullDistanceRef.current >= TRIGGER_PULL) { + triggerHaptic('medium'); setRefreshing(true); updatePull(TRIGGER_PULL); try { diff --git a/resources/js/guest/components/settings-sheet.tsx b/resources/js/guest/components/settings-sheet.tsx index 9139afc..751e144 100644 --- a/resources/js/guest/components/settings-sheet.tsx +++ b/resources/js/guest/components/settings-sheet.tsx @@ -14,12 +14,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Alert, AlertDescription } from '@/components/ui/alert'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle, LifeBuoy } from 'lucide-react'; import { useOptionalGuestIdentity } from '../context/GuestIdentityContext'; import { LegalMarkdown } from './legal-markdown'; import { useLocale, type LocaleContextValue } from '../i18n/LocaleContext'; import { useTranslation } from '../i18n/useTranslation'; import type { LocaleCode } from '../i18n/messages'; +import { useHapticsPreference } from '../hooks/useHapticsPreference'; +import { triggerHaptic } from '../lib/haptics'; const legalPages = [ { slug: 'impressum', translationKey: 'settings.legal.section.impressum' }, @@ -262,6 +265,7 @@ function HomeView({ helpHref, }: HomeViewProps) { const { t } = useTranslation(); + const { enabled: hapticsEnabled, setEnabled: setHapticsEnabled, supported: hapticsSupported } = useHapticsPreference(); const legalLinks = React.useMemo( () => legalPages.map((page) => ({ @@ -355,6 +359,32 @@ function HomeView({ )} + + + {t('settings.haptics.title')} + {t('settings.haptics.description')} + + +
+ {t('settings.haptics.label')} + { + setHapticsEnabled(checked); + if (checked) { + triggerHaptic('selection'); + } + }} + disabled={!hapticsSupported} + aria-label={t('settings.haptics.label')} + /> +
+ {!hapticsSupported && ( +
{t('settings.haptics.unsupported')}
+ )} +
+
+ diff --git a/resources/js/guest/hooks/useDirectUpload.ts b/resources/js/guest/hooks/useDirectUpload.ts index f4af27e..1b21de9 100644 --- a/resources/js/guest/hooks/useDirectUpload.ts +++ b/resources/js/guest/hooks/useDirectUpload.ts @@ -8,6 +8,7 @@ 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; @@ -106,6 +107,7 @@ export function useDirectUpload({ eventToken, taskId, emotionSlug, onCompleted } if (taskId) { markCompleted(taskId); } + triggerHaptic('success'); try { const raw = localStorage.getItem('my-photo-ids'); @@ -121,6 +123,7 @@ export function useDirectUpload({ eventToken, taskId, emotionSlug, onCompleted } 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 | undefined; const dialog = resolveUploadErrorDialog(uploadErr.code, meta, (v: string) => v); diff --git a/resources/js/guest/hooks/useHapticsPreference.ts b/resources/js/guest/hooks/useHapticsPreference.ts new file mode 100644 index 0000000..9219c27 --- /dev/null +++ b/resources/js/guest/hooks/useHapticsPreference.ts @@ -0,0 +1,19 @@ +import React from 'react'; +import { getHapticsPreference, setHapticsPreference, supportsHaptics } from '../lib/haptics'; + +export function useHapticsPreference() { + const [enabled, setEnabledState] = React.useState(() => getHapticsPreference()); + const [supported, setSupported] = React.useState(() => supportsHaptics()); + + React.useEffect(() => { + setEnabledState(getHapticsPreference()); + setSupported(supportsHaptics()); + }, []); + + const setEnabled = React.useCallback((value: boolean) => { + setHapticsPreference(value); + setEnabledState(value); + }, []); + + return { enabled, setEnabled, supported }; +} diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index 1ae37f3..77042b1 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -653,6 +653,12 @@ export const messages: Record = { saved: 'Gespeichert (ok)', loading: 'Lade gespeicherten Namen...', }, + haptics: { + title: 'Haptisches Feedback', + description: 'Kurze Vibrationen bei Likes, Uploads und Aktualisierungen.', + label: 'Vibrationen aktivieren', + unsupported: 'Auf diesem Gerät nicht verfügbar.', + }, legal: { title: 'Rechtliches', description: 'Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.', @@ -1345,6 +1351,12 @@ export const messages: Record = { saved: 'Saved', loading: 'Loading saved name...', }, + haptics: { + title: 'Haptic feedback', + description: 'Short vibrations for likes, uploads, and refreshes.', + label: 'Enable vibrations', + unsupported: 'Not available on this device.', + }, legal: { title: 'Legal', description: 'The legally binding documents are always available here.', diff --git a/resources/js/guest/lib/__tests__/haptics.test.ts b/resources/js/guest/lib/__tests__/haptics.test.ts new file mode 100644 index 0000000..b9f8a69 --- /dev/null +++ b/resources/js/guest/lib/__tests__/haptics.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { HAPTICS_STORAGE_KEY, getHapticsPreference, isHapticsEnabled, setHapticsPreference, supportsHaptics, triggerHaptic } from '../haptics'; + +describe('haptics', () => { + afterEach(() => { + window.localStorage.removeItem(HAPTICS_STORAGE_KEY); + }); + + it('returns false when vibrate is unavailable', () => { + const original = navigator.vibrate; + Object.defineProperty(navigator, 'vibrate', { configurable: true, value: undefined }); + expect(supportsHaptics()).toBe(false); + Object.defineProperty(navigator, 'vibrate', { configurable: true, value: original }); + }); + + it('returns stored preference when set', () => { + window.localStorage.removeItem(HAPTICS_STORAGE_KEY); + expect(getHapticsPreference()).toBe(true); + setHapticsPreference(false); + expect(getHapticsPreference()).toBe(false); + }); + + it('reports disabled when reduced motion is enabled', () => { + const originalMatchMedia = window.matchMedia; + const vibrate = vi.fn(); + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockReturnValue({ matches: true }), + }); + Object.defineProperty(navigator, 'vibrate', { configurable: true, value: vibrate }); + setHapticsPreference(true); + + expect(isHapticsEnabled()).toBe(false); + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: originalMatchMedia, + }); + }); + + it('triggers vibration only when enabled', () => { + const originalMatchMedia = window.matchMedia; + const vibrate = vi.fn(); + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockReturnValue({ matches: false }), + }); + Object.defineProperty(navigator, 'vibrate', { configurable: true, value: vibrate }); + + triggerHaptic('selection'); + expect(vibrate).toHaveBeenCalled(); + setHapticsPreference(false); + triggerHaptic('selection'); + expect(vibrate).toHaveBeenCalledTimes(1); + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: originalMatchMedia, + }); + }); +}); diff --git a/resources/js/guest/lib/haptics.ts b/resources/js/guest/lib/haptics.ts new file mode 100644 index 0000000..48eefba --- /dev/null +++ b/resources/js/guest/lib/haptics.ts @@ -0,0 +1,62 @@ +import { prefersReducedMotion } from './motion'; + +export type HapticPattern = 'selection' | 'light' | 'medium' | 'success' | 'error'; + +const PATTERNS: Record = { + selection: 10, + light: 15, + medium: 30, + success: [10, 30, 10], + error: [20, 30, 20], +}; + +export const HAPTICS_STORAGE_KEY = 'guestHapticsEnabled'; + +export function supportsHaptics(): boolean { + return typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function'; +} + +export function getHapticsPreference(): boolean { + if (typeof window === 'undefined') { + return true; + } + + try { + const raw = window.localStorage.getItem(HAPTICS_STORAGE_KEY); + if (raw === null) { + return true; + } + return raw !== '0'; + } catch (error) { + console.warn('Failed to read haptics preference', error); + return true; + } +} + +export function setHapticsPreference(enabled: boolean): void { + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem(HAPTICS_STORAGE_KEY, enabled ? '1' : '0'); + } catch (error) { + console.warn('Failed to store haptics preference', error); + } +} + +export function isHapticsEnabled(): boolean { + return getHapticsPreference() && supportsHaptics() && !prefersReducedMotion(); +} + +export function triggerHaptic(pattern: HapticPattern): void { + if (!isHapticsEnabled()) { + return; + } + + try { + navigator.vibrate(PATTERNS[pattern]); + } catch (error) { + console.warn('Haptic feedback failed', error); + } +} diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index 04d0837..8e781cd 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -18,6 +18,7 @@ import { useEventBranding } from '../context/EventBrandingContext'; import ShareSheet from '../components/ShareSheet'; import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion'; import PullToRefresh from '../components/PullToRefresh'; +import { triggerHaptic } from '../lib/haptics'; const allGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth']; type GalleryPhoto = { @@ -182,6 +183,7 @@ export default function GalleryPage() { try { const c = await likePhoto(id); setCounts((m) => ({ ...m, [id]: c })); + triggerHaptic('selection'); // keep a simple record of liked items try { const raw = localStorage.getItem('liked-photo-ids'); diff --git a/resources/js/guest/pages/PhotoLightbox.tsx b/resources/js/guest/pages/PhotoLightbox.tsx index 797f3fe..e5e043f 100644 --- a/resources/js/guest/pages/PhotoLightbox.tsx +++ b/resources/js/guest/pages/PhotoLightbox.tsx @@ -11,6 +11,7 @@ import { useToast } from '../components/ToastHost'; import ShareSheet from '../components/ShareSheet'; import { useEventBranding } from '../context/EventBrandingContext'; import { getDeviceId } from '../lib/device'; +import { triggerHaptic } from '../lib/haptics'; type Photo = { id: number; @@ -215,6 +216,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh try { const count = await likePhoto(photo.id); setLikes(count); + triggerHaptic('selection'); // Update localStorage try { const raw = localStorage.getItem('liked-photo-ids'); diff --git a/resources/js/guest/pages/SettingsPage.tsx b/resources/js/guest/pages/SettingsPage.tsx index 0002420..e38e02b 100644 --- a/resources/js/guest/pages/SettingsPage.tsx +++ b/resources/js/guest/pages/SettingsPage.tsx @@ -1,12 +1,44 @@ import React from 'react'; import { Page } from './_util'; import { useTranslation } from '../i18n/useTranslation'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { useHapticsPreference } from '../hooks/useHapticsPreference'; +import { triggerHaptic } from '../lib/haptics'; export default function SettingsPage() { const { t } = useTranslation(); + const { enabled: hapticsEnabled, setEnabled: setHapticsEnabled, supported: hapticsSupported } = useHapticsPreference(); return ( -

{t('settings.subtitle')}

+

{t('settings.subtitle')}

+
+ + + {t('settings.haptics.title')} + {t('settings.haptics.description')} + + +
+ {t('settings.haptics.label')} + { + setHapticsEnabled(checked); + if (checked) { + triggerHaptic('selection'); + } + }} + disabled={!hapticsSupported} + aria-label={t('settings.haptics.label')} + /> +
+ {!hapticsSupported && ( +
{t('settings.haptics.unsupported')}
+ )} +
+
+
); } diff --git a/resources/js/guest/pages/TaskPickerPage.tsx b/resources/js/guest/pages/TaskPickerPage.tsx index 9455ba8..ffd72ce 100644 --- a/resources/js/guest/pages/TaskPickerPage.tsx +++ b/resources/js/guest/pages/TaskPickerPage.tsx @@ -20,6 +20,7 @@ import { import { getDeviceId } from '../lib/device'; import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion'; import PullToRefresh from '../components/PullToRefresh'; +import { triggerHaptic } from '../lib/haptics'; interface Task { id: number; @@ -226,10 +227,12 @@ export default function TaskPickerPage() { const handleNewTask = React.useCallback(() => { selectRandomTask(filteredTasks); + triggerHaptic('selection'); }, [filteredTasks, selectRandomTask]); const handleStartUpload = () => { if (!currentTask || !eventKey) return; + triggerHaptic('light'); navigate(`/e/${encodeURIComponent(eventKey)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`); }; @@ -240,6 +243,7 @@ export default function TaskPickerPage() { const handleSelectTask = React.useCallback((task: Task) => { setCurrentTask(task); + triggerHaptic('selection'); }, []); const handleRetryFetch = () => {