## Duo-Style Onboarding (Foundation + alle Slides) Self-contained Onboarding-Flow mit Lyra-Mascot ersetzt das Spotlight-POC vom vorherigen Iteration. Slides leben unter `components/onboarding/slides/`. - Foundation: OnboardingShell (Progress + ScrollView + sticky CTABar), LyraBubble (Rive-Avatar + animierte Speech-Bubble), SlideProgress, CTABar - Slides: Welcome, Privacy (4 Versprechen), Nickname (inline + PATCH /me), DigaChoice (Ja/Nein-Branch), DigaCode (redeem-Endpoint + inline-Errors), Plan (Pro/Legend cards, monthly/yearly toggle, 2 Monate gratis, Härtefall- Mailto), Payment (RevenueCat-Dev-Stub bis Phase-0), Protection (activate + PermissionDeniedSheet-Wiring), Done (animierter Checkmark + Streak-Day-1) - State-Machine in app/onboarding/index.tsx: 9 Slides, DiGA-Branch, Resume- on-launch via slideFromStep(me.onboardingStep) - Routing-gate in (app)/_layout.tsx: step != 'done' → /onboarding - Backend Profile.onboardingStep enum extended: welcome | account | plan | pre_protection | done (+ legacy nickname/block) - Backend diga redeem: step='pre_protection' (NICHT 'done') — User muss noch durch Protection-Slide für NEFilter/VPN-Aktivierung - Locale-Keys (de/en/fr/ar): onboarding.lyra.<slide>.body, .cta_primary, Plan-Tier-Details (3,99/7,99 €/Mo, 39,90/79,90 €/Jahr mit 2 Monaten gratis), Härtefall-Link, DiGA-Code-Errors, Protection-Feat-Descriptions ## Cooldown Auto-Disable Race-Fix Bug: nach Cooldown-Ablauf bleib URL-Filter installiert (NEFilter in iOS- Settings sichtbar als "Läuft..."). Root-cause: `/api/cooldown/status` GET auto-resolved beim ersten expired-Hit; zweiter Call in applyCooldownDisableIfElapsed sah cooldownEndsAt=null → bail → forceDisable nie aufgerufen. - useProtectionState.fetchState: lokalen next.cooldown.endsAt state nutzen statt redundantem API-Call. Atomarer, race-frei. - AppState-Listener-Path unverändert (dort ist es der erste API-Call, kein Race). - lib/protection.forceDisable: console.log für Debug-Visibility. ## iOS NEFilter Robust-Disable (Native) `removeFromPreferences()` alleine ist auf iOS 18+ unzuverlässig — Settings- UI zeigt "Läuft..." obwohl Provider beendet sein sollte. 2-Step-Pattern: 1. loadFromPreferences 2. isEnabled = false + saveToPreferences (stoppt Filter-Daemon) 3. removeFromPreferences (Config-Eintrag aus Settings) Quelle: Apple-Developer-Forums + eigene Empirie. Pattern wird auch in PermissionDeniedSheet's resetUrlFilter genutzt (analog). ## Family Controls jetzt immer aktiv Apple-Entitlement seit 2026-05 für ReBreak approved (TestFlight-akzeptiert). `familyControlsEnabled: true` hart in app.config.ts (kein Env-Var-Gating mehr). "Bald verfügbar"-Placeholder in blocker.tsx entfernt — App-Lock-Toggle ist jetzt voll funktional auf iOS. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
66 lines
2.0 KiB
TypeScript
66 lines
2.0 KiB
TypeScript
import { usePrisma } from "../utils/prisma";
|
|
|
|
/**
|
|
* Versucht einen DiGA-Code für den User einzulösen.
|
|
*
|
|
* Validierung:
|
|
* - Code existiert
|
|
* - Code wurde noch nicht eingelöst (used_at IS NULL)
|
|
* - Code ist nicht abgelaufen (expires_at IS NULL OR expires_at > NOW)
|
|
*
|
|
* Bei Erfolg (atomar in einer Transaktion):
|
|
* - diga_codes: used_at = NOW(), used_by_profile_id = userId
|
|
* - profiles: plan = code.grants_plan, onboarding_step = 'done',
|
|
* diga_code_redeemed_at = NOW()
|
|
*
|
|
* Returns `null` wenn der Code ungültig ist (für saubere 4xx-Errors im Endpoint).
|
|
*/
|
|
export type RedeemResult =
|
|
| { ok: true; plan: string; codeId: string }
|
|
| { ok: false; reason: "not_found" | "already_used" | "expired" };
|
|
|
|
export async function redeemDigaCode(
|
|
userId: string,
|
|
rawCode: string,
|
|
): Promise<RedeemResult> {
|
|
const code = rawCode.trim().toUpperCase();
|
|
const db = usePrisma();
|
|
|
|
return db.$transaction(async (tx) => {
|
|
const found = await tx.digaCode.findUnique({
|
|
where: { code },
|
|
select: { id: true, usedAt: true, expiresAt: true, grantsPlan: true },
|
|
});
|
|
|
|
if (!found) {
|
|
return { ok: false as const, reason: "not_found" as const };
|
|
}
|
|
if (found.usedAt) {
|
|
return { ok: false as const, reason: "already_used" as const };
|
|
}
|
|
if (found.expiresAt && found.expiresAt.getTime() < Date.now()) {
|
|
return { ok: false as const, reason: "expired" as const };
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
await tx.digaCode.update({
|
|
where: { id: found.id },
|
|
data: { usedAt: now, usedByProfileId: userId },
|
|
});
|
|
|
|
// step → 'pre_protection' (NICHT 'done') — User muss noch durch den
|
|
// Protection-Slide (NEFilter/VPN-Aktivierung auf dem Device).
|
|
await tx.profile.update({
|
|
where: { id: userId },
|
|
data: {
|
|
plan: found.grantsPlan,
|
|
onboardingStep: "pre_protection",
|
|
digaCodeRedeemedAt: now,
|
|
},
|
|
});
|
|
|
|
return { ok: true as const, plan: found.grantsPlan, codeId: found.id };
|
|
});
|
|
}
|