feat(native): device-info api headers + DeviceLimitSheet UI + profile i18n sweep

- lib/api.ts: sends x-device-name + x-device-model + x-device-os headers
  (cached per session, URL-encoded). Backend persists into user_devices for
  visual differentiation in DeviceLimitSheet.
- DeviceLimitReachedSheet: renders name (primary) + model · OS-version
  (secondary), "Dieses Gerät"-Pill on isCurrent. Stale phantoms become
  distinguishable.
- Profile i18n sweep: 8 keys × 3 languages = 24 fixes — all {{var}} placeholders
  switched to %{var} matching i18next config (Vue-i18n leftover from Nuxt-port).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-15 21:16:22 +02:00
parent 5b1f89e749
commit 804d4a5861
6 changed files with 96 additions and 46 deletions

View File

@ -42,6 +42,16 @@ function DeviceLimitRow({
const { t } = useTranslation();
const colors = useColors();
const primaryName = device.name ?? device.model ?? device.platform;
const secondaryParts: string[] = [];
if (device.model) secondaryParts.push(device.model);
if (device.osVersion) {
const osLabel = device.platform === 'android' ? 'Android' : 'iOS';
secondaryParts.push(`${osLabel} ${device.osVersion}`);
}
const secondaryLine = secondaryParts.join(' · ');
const showSecondary = secondaryLine.length > 0 && secondaryLine !== primaryName;
return (
<View
style={{
@ -66,18 +76,40 @@ function DeviceLimitRow({
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text
numberOfLines={1}
style={{
fontSize: 15,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
flexShrink: 1,
}}
>
{device.name ?? device.model ?? device.platform}
</Text>
{device.model && device.name && !device.name.includes(device.model) ? (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text
numberOfLines={1}
style={{
fontSize: 15,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
flexShrink: 1,
}}
>
{primaryName}
</Text>
{device.isCurrent ? (
<View
style={{
backgroundColor: 'rgba(59,130,246,0.12)',
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 2,
}}
>
<Text
style={{
fontSize: 10,
color: '#3b82f6',
fontFamily: 'Nunito_700Bold',
}}
>
{t('device_limit.this_device')}
</Text>
</View>
) : null}
</View>
{showSecondary ? (
<Text
style={{
fontSize: 11,
@ -86,7 +118,7 @@ function DeviceLimitRow({
marginTop: 1,
}}
>
{device.model}
{secondaryLine}
</Text>
) : null}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>

View File

@ -1,6 +1,6 @@
import Constants from 'expo-constants';
import { supabase } from './supabase';
import { getDeviceId, getPlatformName } from './deviceId';
import { getDeviceInfo } from './deviceId';
import { useDeviceLimitStore } from '../stores/deviceLimit';
const apiUrl = Constants.expoConfig?.extra?.apiUrl as string;
@ -11,6 +11,22 @@ type FetchOptions = Omit<RequestInit, 'body'> & {
skipDeviceHeader?: boolean;
};
let cachedDeviceHeaders: Record<string, string> | null = null;
async function getDeviceHeaders(): Promise<Record<string, string>> {
if (cachedDeviceHeaders) return cachedDeviceHeaders;
const info = await getDeviceInfo().catch(() => null);
if (!info) return {};
cachedDeviceHeaders = {
'x-device-id': info.deviceId,
'x-platform': info.platform,
'x-device-name': encodeURIComponent(info.name),
'x-device-model': encodeURIComponent(info.model),
'x-device-os': encodeURIComponent(info.osVersion),
};
return cachedDeviceHeaders;
}
/**
* Wrapper für Backend-API-Calls mit automatischem Auth-Token.
* Pendant zum Nuxt-`useSafeFetch` aus apps/rebreak/.
@ -35,11 +51,8 @@ export async function apiFetch<T = any>(
}
if (!skipDeviceHeader) {
const deviceId = await getDeviceId().catch(() => null);
if (deviceId) {
headers['x-device-id'] = deviceId;
headers['x-platform'] = getPlatformName();
}
const deviceHeaders = await getDeviceHeaders();
Object.assign(headers, deviceHeaders);
}
const res = await fetch(`${apiUrl}${path}`, {

View File

@ -589,7 +589,8 @@
"title": "Geräte-Limit erreicht",
"subtitle": "%{count} von %{max} Geräten belegt (%{plan}) — entferne ein Gerät um weiterzumachen",
"hint": "Entfernte Geräte können sich beim nächsten Login wieder registrieren.",
"remove_cta": "Gerät entfernen"
"remove_cta": "Gerät entfernen",
"this_device": "Dieses Gerät"
},
"urge": {
"title": "SOS — Atemübung",
@ -783,22 +784,22 @@
"crop_reset": "Zurücksetzen",
"streak_section_label": "STREAK",
"streak_days_protected": "Tage geschützt",
"streak_since": "seit {{date}}",
"streak_longest": "Längste Streak: {{days}} Tage",
"streak_since": "seit %{date}",
"streak_longest": "Längste Streak: %{days} Tage",
"cooldown": {
"heading": "COOLDOWN-VERLAUF",
"window_label": "letzte {{weeks}}W",
"week_label": "W{{n}}",
"window_label": "letzte %{weeks}W",
"week_label": "W%{n}",
"none": "Keine Cooldowns in den letzten 8 Wochen",
"count_one": "1 Cooldown in {{weeks}} Wochen",
"count_other": "{{n}} Cooldowns in {{weeks}} Wochen",
"avg_last": "Ø 1 pro {{avg}} Wochen · zuletzt {{date}}",
"count_one": "1 Cooldown in %{weeks} Wochen",
"count_other": "%{n} Cooldowns in %{weeks} Wochen",
"avg_last": "Ø 1 pro %{avg} Wochen · zuletzt %{date}",
"patterns": {
"toggle_label": "Mehr Infos",
"hour_heading": "Wann startest du Cooldowns?",
"day_heading": "An welchen Tagen?",
"reason_heading": "Häufige Begriffe",
"cancel_rate": "Cooldowns abgebrochen: {{pct}}%",
"cancel_rate": "Cooldowns abgebrochen: %{pct}%",
"not_enough": "Noch keine Muster erkannt",
"weekday_mon": "Mo",
"weekday_tue": "Di",

View File

@ -589,7 +589,8 @@
"title": "Device limit reached",
"subtitle": "%{count} of %{max} device slots used (%{plan}) — remove a device to continue",
"hint": "Removed devices can re-register on next sign-in.",
"remove_cta": "Remove device"
"remove_cta": "Remove device",
"this_device": "This device"
},
"urge": {
"title": "SOS — Breathing exercise",
@ -783,22 +784,22 @@
"crop_reset": "Reset",
"streak_section_label": "STREAK",
"streak_days_protected": "days protected",
"streak_since": "since {{date}}",
"streak_longest": "Longest streak: {{days}} days",
"streak_since": "since %{date}",
"streak_longest": "Longest streak: %{days} days",
"cooldown": {
"heading": "COOLDOWN HISTORY",
"window_label": "last {{weeks}}W",
"week_label": "W{{n}}",
"window_label": "last %{weeks}W",
"week_label": "W%{n}",
"none": "No cooldowns in the last 8 weeks",
"count_one": "1 cooldown over {{weeks}} weeks",
"count_other": "{{n}} cooldowns over {{weeks}} weeks",
"avg_last": "Ø 1 every {{avg}} weeks · last {{date}}",
"count_one": "1 cooldown over %{weeks} weeks",
"count_other": "%{n} cooldowns over %{weeks} weeks",
"avg_last": "Ø 1 every %{avg} weeks · last %{date}",
"patterns": {
"toggle_label": "More insights",
"hour_heading": "When do you start cooldowns?",
"day_heading": "Which days?",
"reason_heading": "Common terms",
"cancel_rate": "Cooldowns cancelled: {{pct}}%",
"cancel_rate": "Cooldowns cancelled: %{pct}%",
"not_enough": "Not enough patterns yet",
"weekday_mon": "Mon",
"weekday_tue": "Tue",

View File

@ -589,7 +589,8 @@
"title": "Limite d'appareils atteinte",
"subtitle": "%{count} sur %{max} emplacements utilisés (%{plan}) — supprimez un appareil pour continuer",
"hint": "Les appareils supprimés peuvent se réenregistrer à la prochaine connexion.",
"remove_cta": "Supprimer un appareil"
"remove_cta": "Supprimer un appareil",
"this_device": "Cet appareil"
},
"urge": {
"title": "SOS — Exercice de respiration",
@ -783,22 +784,22 @@
"crop_reset": "Réinitialiser",
"streak_section_label": "SÉRIE",
"streak_days_protected": "jours protégés",
"streak_since": "depuis {{date}}",
"streak_longest": "Série la plus longue : {{days}} jours",
"streak_since": "depuis %{date}",
"streak_longest": "Série la plus longue : %{days} jours",
"cooldown": {
"heading": "HISTORIQUE DES PAUSES",
"window_label": "{{weeks}} dernières sem.",
"week_label": "S{{n}}",
"window_label": "%{weeks} dernières sem.",
"week_label": "S%{n}",
"none": "Aucune pause dans les 8 dernières semaines",
"count_one": "1 pause sur {{weeks}} semaines",
"count_other": "{{n}} pauses sur {{weeks}} semaines",
"avg_last": "Ø 1 toutes les {{avg}} semaines · dernière {{date}}",
"count_one": "1 pause sur %{weeks} semaines",
"count_other": "%{n} pauses sur %{weeks} semaines",
"avg_last": "Ø 1 toutes les %{avg} semaines · dernière %{date}",
"patterns": {
"toggle_label": "Plus d'infos",
"hour_heading": "Quand démarrez-vous des pauses ?",
"day_heading": "Quels jours ?",
"reason_heading": "Termes fréquents",
"cancel_rate": "Pauses annulées : {{pct}}%",
"cancel_rate": "Pauses annulées : %{pct}%",
"not_enough": "Pas encore assez de données",
"weekday_mon": "Lun",
"weekday_tue": "Mar",

View File

@ -6,8 +6,10 @@ export type DeviceLimitDevice = {
platform: string;
model: string | null;
name: string | null;
osVersion: string | null;
lastSeenAt: string;
createdAt: string;
isCurrent?: boolean;
};
type DeviceLimitState = {