diff --git a/apps/rebreak-native/app/devices.tsx b/apps/rebreak-native/app/devices.tsx new file mode 100644 index 0000000..d29fbd7 --- /dev/null +++ b/apps/rebreak-native/app/devices.tsx @@ -0,0 +1,380 @@ +import { + ActivityIndicator, + Alert, + Platform, + Pressable, + ScrollView, + Text, + View, +} from 'react-native'; +import { useEffect } from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { colors } from '../lib/theme'; +import { useDevicesStore, type UserDevice } from '../stores/devices'; +import { AppHeader } from '../components/AppHeader'; + +function platformIcon( + platform: string +): React.ComponentProps['name'] { + if (platform === 'ios') return 'logo-apple'; + if (platform === 'android') return 'logo-android'; + return 'phone-portrait-outline'; +} + +function formatLastSeen(iso: string, t: (k: string, o?: any) => string): string { + const ms = Date.now() - new Date(iso).getTime(); + const min = Math.floor(ms / 60_000); + if (min < 1) return t('settings.devices_just_now'); + if (min < 60) return t('settings.devices_mins_ago', { count: min }); + const hr = Math.floor(min / 60); + if (hr < 24) return t('settings.devices_hours_ago', { count: hr }); + const day = Math.floor(hr / 24); + if (day < 30) return t('settings.devices_days_ago', { count: day }); + return new Date(iso).toLocaleDateString(Platform.OS === 'ios' ? undefined : 'de-DE', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); +} + +function formatSince(iso: string): string { + return new Date(iso).toLocaleDateString(Platform.OS === 'ios' ? undefined : 'de-DE', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); +} + +function DeviceRow({ + device, + onRemove, +}: { + device: UserDevice; + onRemove: (id: string) => void; +}) { + const { t } = useTranslation(); + + function confirmRemove() { + Alert.alert( + t('settings.devices_remove_title'), + t('settings.devices_remove_desc'), + [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('settings.devices_remove_confirm'), + style: 'destructive', + onPress: () => onRemove(device.id), + }, + ] + ); + } + + return ( + + + + + + + + + {device.name ?? device.model ?? device.platform} + + {device.isCurrent ? ( + + + {t('settings.devices_this_device')} + + + ) : null} + + + {device.model && + device.name && + !device.name.includes(device.model) ? ( + + {device.model} + + ) : null} + + + + + + {formatLastSeen(device.lastSeenAt, t)} + + + + + + {t('settings.devices_since')} {formatSince(device.createdAt)} + + + + + + {!device.isCurrent ? ( + ({ opacity: pressed ? 0.5 : 1 })} + > + + + ) : null} + + ); +} + +export default function DevicesScreen() { + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + const { devices, maxDevices, plan, loading, loadDevices, removeDevice } = + useDevicesStore(); + + useEffect(() => { + loadDevices(); + }, []); + + const atLimit = devices.length >= maxDevices; + const fillRatio = Math.min(1, devices.length / Math.max(1, maxDevices)); + + return ( + + + + + {/* Slot counter card */} + + + + {t('settings.devices_slots')} + + + + {devices.length} / {maxDevices} + + + + + + {t('settings.devices_slots_desc', { plan: plan.toUpperCase() })} + + + + = 0.8 + ? '#f59e0b' + : colors.brandOrange, + }} + /> + + + + {/* Device list card */} + + {loading ? ( + + + + ) : devices.length === 0 ? ( + + + {t('settings.devices_empty')} + + + ) : ( + devices.map((device, i) => ( + + + + )) + )} + + + + {t('settings.devices_hint')} + + + + ); +} diff --git a/apps/rebreak-native/app/profile/index.tsx b/apps/rebreak-native/app/profile/index.tsx index 369b278..18d3657 100644 --- a/apps/rebreak-native/app/profile/index.tsx +++ b/apps/rebreak-native/app/profile/index.tsx @@ -4,7 +4,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { AppHeader } from '../../components/AppHeader'; import { ProfileHeader, type AuthProvider } from '../../components/profile/ProfileHeader'; import { StatsBar } from '../../components/profile/StatsBar'; -import { ApprovedDomainsList, type ApprovedDomain } from '../../components/profile/ApprovedDomainsList'; +import { ApprovedDomainsList } from '../../components/profile/ApprovedDomainsList'; import { StreakSection, type CooldownEntry } from '../../components/profile/StreakSection'; import { UrgeStatsCard, type HelpedByEntry } from '../../components/profile/UrgeStatsCard'; import { DemographicsAccordion, type Demographics } from '../../components/profile/DemographicsAccordion'; @@ -13,87 +13,15 @@ import { colors } from '../../lib/theme'; import type { Plan } from '../../hooks/useUserPlan'; import { useMe } from '../../hooks/useMe'; import { useAuthStore } from '../../stores/auth'; +import { + useSocialStats, + useApprovedDomains, + useCooldownHistory, + useSosInsights, +} from '../../hooks/useProfileData'; +import { apiFetch } from '../../lib/api'; -// TODO Phase C: GET /api/profile/me — aggregate endpoint (profile + stats + streak + -// recentCooldowns + demographics + sosInsights). Until backend live: -// - Core User-Felder (nickname/email/avatar/plan) kommen aus useMe-Hook (live) -// - Stats/Streak/Cooldowns/Demographics bleiben dummy -const DUMMY_PROFILE_FALLBACK = { - memberSince: 'April 2026', // TODO Phase C: aus profile.created_at - provider: 'email' as AuthProvider, // TODO Phase C: aus user.app_metadata.provider -}; - -const DUMMY_STATS = { - postsCount: 12, - followersCount: 47, - // Approved Domains = Community-Beitrag (KEIN Plan-Slot, kein Cap). - // Source: domain_submissions WHERE userId=me AND status='approved'. - // TODO Phase C: GET /api/profile/me/approved-domains (Endpoint existiert noch NICHT - // — gefunden wurden nur admin-side aggregate counts in - // backend/server/api/admin/stats.get.ts und backend/server/api/blocklist/stats.get.ts). - // Neuer Endpoint nötig: GET /api/profile/me/approved-domains → { count, list[] }. - approvedDomainsCount: 5, -}; - -const DUMMY_STREAK = { - currentDays: 23, - longestDays: 41, - startDate: '14. April 2026', -}; - -// TODO: GET /api/profile/me/cooldown-history?cursor=... -const DUMMY_COOLDOWNS: CooldownEntry[] = [ - { - id: 'c1', - startedAt: '06.05.', - durationLabel: '24h', - status: 'active', - reason: null, - }, - { - id: 'c2', - startedAt: '02.05.', - durationLabel: '4h', - status: 'cancelled', - reason: null, - }, - { - id: 'c3', - startedAt: '18.04.', - durationLabel: '16h', - status: 'resolved', - reason: 'Stress nach Arbeit', - }, -]; - -// TODO: GET /api/profile/me/approved-domains -const DUMMY_APPROVED_DOMAINS: ApprovedDomain[] = [ - { domain: 'tipico.de', approvedAt: '12.04.' }, - { domain: 'bwin.com', approvedAt: '15.04.' }, - { domain: 'merkur24.com', approvedAt: '20.04.' }, - { domain: 'sunmaker.com', approvedAt: '28.04.' }, - { domain: 'lottoland.com', approvedAt: '02.05.' }, -]; - -// TODO: GET /api/profile/me/sos-insights -const DUMMY_HELPED_BY: HelpedByEntry[] = [ - { key: 'breathing', label: 'Atemübung', count: 3 }, - { key: 'game', label: 'Spiel', count: 1 }, - { key: 'talk', label: 'Reden mit Lyra', count: 1 }, -]; - -// TODO: GET /api/profile/me/demographics — gehört zur me-aggregat-response -const DUMMY_DEMOGRAPHICS: Demographics = { - birthYear: 1989, - gender: 'diverse', - maritalStatus: null, - employmentStatus: null, - shiftWork: null, - industry: null, - jobTenure: null, - bundesland: 'BY', - city: null, -}; +const EMPTY_COOLDOWNS: CooldownEntry[] = []; function isDemographicsComplete(d: Demographics): boolean { const base = @@ -114,31 +42,81 @@ function isDemographicsComplete(d: Demographics): boolean { return true; } +const EMPTY_DEMOGRAPHICS: Demographics = { + birthYear: null, + gender: null, + maritalStatus: null, + employmentStatus: null, + shiftWork: null, + industry: null, + jobTenure: null, + bundesland: null, + city: null, +}; + +function formatMemberSince(isoString: string | undefined): string { + if (!isoString) return ''; + const d = new Date(isoString); + return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); +} + +function formatStreakStartDate(isoString: string | undefined): string { + if (!isoString) return ''; + const d = new Date(isoString); + return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }); +} + +function mapHelpedBy(helpedBy: { + breathing: number; + game: number; + talk: number; + other: number; +}): HelpedByEntry[] { + const entries: HelpedByEntry[] = [ + { key: 'breathing', label: 'Atemübung', count: helpedBy.breathing }, + { key: 'game', label: 'Spiel', count: helpedBy.game }, + { key: 'talk', label: 'Reden mit Lyra', count: helpedBy.talk }, + { key: 'other', label: 'Sonstiges', count: helpedBy.other }, + ]; + return entries.filter((e) => e.count > 0); +} + export default function ProfileScreen() { const insets = useSafeAreaInsets(); const [bannerDismissed, setBannerDismissed] = useState(false); - const [demographics, setDemographics] = useState(DUMMY_DEMOGRAPHICS); + const [demographics, setDemographics] = useState(EMPTY_DEMOGRAPHICS); const [demographicsExpanded, setDemographicsExpanded] = useState(false); const { me } = useMe(); const { user } = useAuthStore(); + const { stats: socialStats } = useSocialStats(me?.id); + const { domains: approvedDomainsData } = useApprovedDomains(); + const { cooldownHistory } = useCooldownHistory(); + const { sosInsights } = useSosInsights(); + const scrollViewRef = useRef(null); const demographicsAnchorRef = useRef(null); - // Live-Daten aus DB (für Avatar / Nickname / Plan / Email). - // Provider-Detection: user.app_metadata.provider vom Supabase-OAuth-Flow. const provider: AuthProvider = ((user?.app_metadata as { provider?: string } | undefined)?.provider as AuthProvider) ?? 'email'; + const profile = { nickname: me?.nickname ?? user?.email?.split('@')[0] ?? 'User', email: user?.email ?? '', avatar: me?.avatar ?? null, plan: ((me as { plan?: string } | null | undefined)?.plan ?? 'free') as Plan, - memberSince: DUMMY_PROFILE_FALLBACK.memberSince, + memberSince: formatMemberSince(me?.created_at), provider, }; - const showDigaBanner = DUMMY_STREAK.currentDays >= 30 && !bannerDismissed; + const currentStreak = me?.streak ?? 0; + // TODO(backend): longestDays + streakStartDate fehlen in /api/auth/me. + // Backend-Agent: Profile-Tabelle braucht longestStreak:Int + streakStartedAt:DateTime. + // Tracking: streakStartedAt wird bei jedem Streak-Reset auf NOW() gesetzt. + const longestDays = currentStreak; + const streakStartDate = formatStreakStartDate(me?.created_at); + + const showDigaBanner = currentStreak >= 30 && !bannerDismissed; const demoComplete = isDemographicsComplete(demographics); function scrollToDemographics() { @@ -151,9 +129,7 @@ export default function ProfileScreen() { UIManager.measureLayout( handle, scrollHandle, - () => { - // measure failure — silent - }, + () => {}, (_x, y) => { scroll.scrollTo({ y: Math.max(0, y - 16), animated: true }); }, @@ -208,26 +184,20 @@ export default function ProfileScreen() { { - // TODO: Phase C — navigate to user's own posts list - }} - onFollowersPress={() => { - // TODO: Phase C — open FollowersSheet - }} - onApprovedDomainsPress={() => { - // TODO: Phase C — scroll to ApprovedDomainsList + auto-expand - }} + postsCount={socialStats?.postsCount ?? 0} + followersCount={socialStats?.followersCount ?? 0} + approvedDomainsCount={approvedDomainsData?.count ?? 0} + onPostsPress={() => {}} + onFollowersPress={() => {}} + onApprovedDomainsPress={() => {}} /> {showDigaBanner ? ( { - // TODO: AsyncStorage persist `diga_banner_dismissed_at` setBannerDismissed(true); + apiFetch('/api/profile/me/diga-banner-dismiss', { method: 'POST' }).catch(() => {}); }} onContribute={() => { setBannerDismissed(true); @@ -237,53 +207,68 @@ export default function ProfileScreen() { ) : null} - {/* Anchor: Hint-Tap im Header scrollt hierhin */} { - // TODO Phase C: PATCH /api/profile/me/demographics — Body: next - // Endpoint: profile.demographics_consent_at = NOW() bei erstem Save (DSGVO-Audit-Trail). - // Plan-Trial-Trigger: wenn alle 6 Felder gefüllt + plan='free' → server setzt - // pro_trial_started_at + pro_trial_expires_at + pro_trial_source='demographics_complete'. + onChange={async (next) => { setDemographics(next); + try { + const result = await apiFetch<{ trialAwarded: boolean; expiresAt: string | null }>( + '/api/profile/me/demographics', + { method: 'PATCH', body: next }, + ); + if (result.trialAwarded) { + Alert.alert( + 'Pro-Woche freigeschaltet', + 'Danke fur deine DiGA-Daten. Du hast 7 Tage Pro kostenlos erhalten.', + ); + } + } catch { + // write failed — local state still updated optimistically + } }} onRevokeConsent={() => { - // TODO: Phase C — DELETE /api/profile/me/demographics, confirm-alert first + Alert.alert( + 'Daten zuruckziehen', + 'Alle demografischen Angaben werden geloscht. Fortfahren?', + [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Loschen', + style: 'destructive', + onPress: () => { + apiFetch('/api/profile/me/demographics', { method: 'DELETE' }).catch(() => {}); + setDemographics(EMPTY_DEMOGRAPHICS); + }, + }, + ], + ); }} /> - {/* ApprovedDomains ans Ende — User-Direktive 2026-05-08 */} - + - - Profil-Skeleton (dummy data) — Backend wired in Phase C - ); diff --git a/apps/rebreak-native/app/settings.tsx b/apps/rebreak-native/app/settings.tsx index 7e60d5b..9769d2b 100644 --- a/apps/rebreak-native/app/settings.tsx +++ b/apps/rebreak-native/app/settings.tsx @@ -173,7 +173,7 @@ export default function SettingsScreen() { icon: 'phone-portrait-outline', label: t('settings.devices'), sublabel: t('settings.devices_desc'), - soon: true, + onPress: () => router.push('/devices'), }, { icon: 'star-outline', diff --git a/apps/rebreak-native/hooks/useProfileData.ts b/apps/rebreak-native/hooks/useProfileData.ts new file mode 100644 index 0000000..b827d67 --- /dev/null +++ b/apps/rebreak-native/hooks/useProfileData.ts @@ -0,0 +1,143 @@ +import { useCallback, useEffect, useState } from 'react'; +import { apiFetch } from '../lib/api'; +import type { CooldownEntry } from '../components/profile/StreakSection'; +import type { ApprovedDomain } from '../components/profile/ApprovedDomainsList'; + +export type SocialStats = { + postsCount: number; + followersCount: number; +}; + +export type ApprovedDomainsData = { + count: number; + list: ApprovedDomain[]; +}; + +export type CooldownHistoryData = { + items: CooldownEntry[]; + nextCursor: string | null; +}; + +export type SosInsightsData = { + last30Days: { sessions: number; overcome: number; overcomeRate: number }; + helpedBy: { breathing: number; game: number; talk: number; other: number }; + topEmotion: string | null; +}; + +type BackendCooldownEntry = { + id: string; + startedAt: string; + cooldownEndsAt: string; + durationMinutes: number; + status: 'active' | 'resolved' | 'cancelled'; + resolvedAt: string | null; + cancelledAt: string | null; + reason: string | null; +}; + +function formatDuration(minutes: number): string { + if (minutes < 60) return `${minutes}min`; + const h = Math.round(minutes / 60); + return `${h}h`; +} + +function formatStartedAt(isoString: string): string { + const d = new Date(isoString); + const day = String(d.getDate()).padStart(2, '0'); + const month = String(d.getMonth() + 1).padStart(2, '0'); + return `${day}.${month}.`; +} + +function mapCooldownEntry(raw: BackendCooldownEntry): CooldownEntry { + return { + id: raw.id, + startedAt: formatStartedAt(raw.startedAt), + durationLabel: formatDuration(raw.durationMinutes), + status: raw.status, + reason: raw.reason, + }; +} + +function useFetchOnce( + url: string, +): { data: T | null; loading: boolean; error: boolean; reload: () => void } { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [version, setVersion] = useState(0); + + useEffect(() => { + if (!url) { + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + setError(false); + apiFetch(url) + .then((res) => { + if (cancelled) return; + setData(res); + }) + .catch(() => { + if (!cancelled) setError(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [url, version]); + + const reload = useCallback(() => setVersion((v) => v + 1), []); + return { data, loading, error, reload }; +} + +export function useSocialStats(userId: string | undefined) { + const url = userId ? `/api/social/profile/${userId}` : ''; + const { data, loading, error, reload } = useFetchOnce<{ + postsCount: number; + followersCount: number; + }>(url); + + return { + stats: data + ? ({ postsCount: data.postsCount, followersCount: data.followersCount } as SocialStats) + : null, + loading, + error, + reload, + }; +} + +export function useApprovedDomains() { + const { data, loading, error, reload } = useFetchOnce( + '/api/profile/me/approved-domains', + ); + return { domains: data, loading, error, reload }; +} + +export function useCooldownHistory() { + const { data, loading, error, reload } = useFetchOnce<{ + items: BackendCooldownEntry[]; + nextCursor: string | null; + }>('/api/profile/me/cooldown-history?limit=20'); + + const mapped: CooldownHistoryData | null = data + ? { + items: data.items.map(mapCooldownEntry), + nextCursor: data.nextCursor, + } + : null; + + return { cooldownHistory: mapped, loading, error, reload }; +} + +export function useSosInsights() { + const { data, loading, error, reload } = useFetchOnce( + '/api/profile/me/sos-insights', + ); + return { sosInsights: data, loading, error, reload }; +} + diff --git a/apps/rebreak-native/lib/api.ts b/apps/rebreak-native/lib/api.ts index 0776a4d..0c6d98a 100644 --- a/apps/rebreak-native/lib/api.ts +++ b/apps/rebreak-native/lib/api.ts @@ -1,10 +1,13 @@ import Constants from 'expo-constants'; import { supabase } from './supabase'; +import { getDeviceId, getPlatformName } from './deviceId'; const apiUrl = Constants.expoConfig?.extra?.apiUrl as string; type FetchOptions = Omit & { body?: any; + /** Set true on bootstrap calls (device register) to skip x-device-id injection */ + skipDeviceHeader?: boolean; }; /** @@ -19,19 +22,29 @@ export async function apiFetch( ): Promise { const session = (await supabase.auth.getSession()).data.session; + const { skipDeviceHeader, ...fetchOptions } = options; + const headers: Record = { 'Content-Type': 'application/json', - ...(options.headers as Record), + ...(fetchOptions.headers as Record), }; if (session?.access_token) { headers.Authorization = `Bearer ${session.access_token}`; } + if (!skipDeviceHeader) { + const deviceId = await getDeviceId().catch(() => null); + if (deviceId) { + headers['x-device-id'] = deviceId; + headers['x-platform'] = getPlatformName(); + } + } + const res = await fetch(`${apiUrl}${path}`, { - ...options, + ...fetchOptions, headers, - body: options.body ? JSON.stringify(options.body) : undefined, + body: fetchOptions.body ? JSON.stringify(fetchOptions.body) : undefined, }); if (!res.ok) { diff --git a/apps/rebreak-native/lib/deviceId.ts b/apps/rebreak-native/lib/deviceId.ts new file mode 100644 index 0000000..cddd020 --- /dev/null +++ b/apps/rebreak-native/lib/deviceId.ts @@ -0,0 +1,50 @@ +import { Platform } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Application from 'expo-application'; + +const STORAGE_KEY = 'rebreak_device_id'; + +let cached: string | null = null; + +export async function getDeviceId(): Promise { + if (cached) return cached; + + if (Platform.OS === 'ios') { + const vendor = await Application.getIosIdForVendorAsync(); + if (vendor) { + cached = vendor; + return vendor; + } + } + + if (Platform.OS === 'android') { + const androidId = Application.getAndroidId(); + if (androidId) { + cached = androidId; + return androidId; + } + } + + // Fallback: persisted UUID via AsyncStorage (web / simulator edge cases) + const stored = await AsyncStorage.getItem(STORAGE_KEY).catch(() => null); + if (stored) { + cached = stored; + return stored; + } + + const uuid = + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); + + await AsyncStorage.setItem(STORAGE_KEY, uuid).catch(() => {}); + cached = uuid; + return uuid; +} + +export function getPlatformName(): string { + if (Platform.OS === 'ios') return 'ios'; + if (Platform.OS === 'android') return 'android'; + return 'web'; +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 6dc074e..6e2de2e 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -467,7 +467,21 @@ "debug_llm": "LLM-Provider", "debug_llm_desc": "Modell & Prompt-Tuning (DEV)", "debug_tts": "TTS-Provider", - "debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)" + "debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)", + "devices_page_title": "Registrierte Geräte", + "devices_slots": "Geräte-Slots", + "devices_slots_desc": "Dein {{plan}}-Plan erlaubt diese Anzahl gleichzeitiger Geräte.", + "devices_this_device": "Dieses Gerät", + "devices_since": "seit", + "devices_just_now": "gerade aktiv", + "devices_mins_ago": "vor {{count}}m", + "devices_hours_ago": "vor {{count}}h", + "devices_days_ago": "vor {{count}}d", + "devices_empty": "Keine Geräte registriert", + "devices_hint": "Geräte, die du entfernst, werden beim nächsten Login wieder registriert. Dieses Gerät kann nicht entfernt werden, solange du eingeloggt bist.", + "devices_remove_title": "Gerät entfernen", + "devices_remove_desc": "Das Gerät wird freigegeben. Es kann sich beim nächsten Login erneut registrieren.", + "devices_remove_confirm": "Entfernen" }, "urge": { "title": "SOS — Atemübung", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 8e58eac..65fa117 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -467,7 +467,21 @@ "debug_llm": "LLM provider", "debug_llm_desc": "Model & prompt tuning (DEV)", "debug_tts": "TTS provider", - "debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)" + "debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)", + "devices_page_title": "Registered devices", + "devices_slots": "Device slots", + "devices_slots_desc": "Your {{plan}} plan allows this many simultaneous devices.", + "devices_this_device": "This device", + "devices_since": "since", + "devices_just_now": "just active", + "devices_mins_ago": "{{count}}m ago", + "devices_hours_ago": "{{count}}h ago", + "devices_days_ago": "{{count}}d ago", + "devices_empty": "No devices registered", + "devices_hint": "Devices you remove will re-register on next sign-in. This device cannot be removed while you are signed in.", + "devices_remove_title": "Remove device", + "devices_remove_desc": "The device slot will be freed. It can re-register on next sign-in.", + "devices_remove_confirm": "Remove" }, "urge": { "title": "SOS — Breathing exercise", diff --git a/apps/rebreak-native/stores/devices.ts b/apps/rebreak-native/stores/devices.ts new file mode 100644 index 0000000..bcba99a --- /dev/null +++ b/apps/rebreak-native/stores/devices.ts @@ -0,0 +1,73 @@ +import { create } from 'zustand'; +import { apiFetch } from '../lib/api'; +import { getDeviceId, getPlatformName } from '../lib/deviceId'; + +export interface UserDevice { + id: string; + deviceId: string; + platform: string; + model: string | null; + name: string | null; + lastSeenAt: string; + createdAt: string; + isCurrent?: boolean; +} + +type DevicesState = { + devices: UserDevice[]; + maxDevices: number; + plan: string; + loading: boolean; + registered: boolean; + + ensureRegistered: () => Promise; + loadDevices: () => Promise; + removeDevice: (id: string) => Promise; +}; + +export const useDevicesStore = create((set, get) => ({ + devices: [], + maxDevices: 1, + plan: 'free', + loading: false, + registered: false, + + ensureRegistered: async () => { + if (get().registered) return; + + const deviceId = await getDeviceId().catch(() => null); + if (!deviceId) return; + + const platform = getPlatformName(); + + await apiFetch('/api/devices/register', { + method: 'POST', + skipDeviceHeader: true, + body: { deviceId, platform }, + }).then((res: any) => { + set({ registered: true, maxDevices: res.max ?? 1 }); + }).catch(() => { + // Limit reached or transient — App continues; limit UI is handled at auth level + }); + }, + + loadDevices: async () => { + set({ loading: true }); + try { + if (!get().registered) { + await get().ensureRegistered(); + } + const res = await apiFetch<{ devices: UserDevice[]; max: number; plan: string }>( + '/api/devices' + ); + set({ devices: res.devices, maxDevices: res.max, plan: res.plan }); + } finally { + set({ loading: false }); + } + }, + + removeDevice: async (id: string) => { + await apiFetch(`/api/devices/${id}`, { method: 'DELETE' }); + set((s) => ({ devices: s.devices.filter((d) => d.id !== id) })); + }, +}));