diff --git a/messages/de.json b/messages/de.json index bc23d2f08..e4a69f8b5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4553,7 +4553,18 @@ "ladderOauthBody": "Verbinde ein ChatGPT-Konto — keine Kosten pro Token, aber die Bedingungen der Verbraucherstufe können Inhalte fürs Training nutzen. Deshalb eine bewusste Wahl, nie der Standard für deine Gesundheitsdaten.", "keylessLine": "HealthLog ist auch ohne KI vollständig nutzbar. Deine Kacheln, Diagramme, Trends und Exporte funktionieren alle — KI ergänzt nur den Klartext obendrauf.", "setupCta": "Einrichten (etwa eine Minute)", - "sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort — keine Einrichtung nötig." + "sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort — keine Einrichtung nötig.", + "offer": { + "title": "Die Zusammenfassung dieser Instanz ist bereit.", + "body": "Der Betreiber stellt einen Anbieter bereit, der zuletzt innerhalb der letzten 24 Stunden funktioniert hat. Mit einem Tipp schaltest du den Klartext ein; deine Daten gehen dann an diesen einen Anbieter, an niemanden sonst.", + "cta": "Klartext einschalten", + "granted": "Eingeschaltet. Die erste Zusammenfassung erscheint auf dem Dashboard.", + "error": "Das Einschalten hat nicht geklappt. Versuch es gleich noch einmal." + }, + "unavailable": { + "title": "Der Anbieter dieser Instanz antwortet gerade nicht.", + "body": "HealthLog funktioniert ohne Klartext vollständig. Du kannst später in den Einstellungen einen eigenen Anbieter hinterlegen." + } }, "steps": { "who": "Für wen diese Akte ist", diff --git a/messages/en.json b/messages/en.json index dc66aa61b..51abfb548 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4553,7 +4553,18 @@ "ladderOauthBody": "Connect a ChatGPT account — no per-token cost, but the consumer tier's terms may use content for training, so it's a deliberate choice, never the default for your health data.", "keylessLine": "HealthLog is fully useful without AI. Your tiles, charts, trends and exports all work — AI only adds the plain-language read on top.", "setupCta": "Set it up (about a minute)", - "sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now — no setup needed." + "sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now — no setup needed.", + "offer": { + "title": "This instance can write your summary in plain language.", + "body": "The operator provides a provider that last worked within the past 24 hours. One tap turns the plain-language read on; your data then goes to that one provider and to nobody else.", + "cta": "Turn on plain language", + "granted": "Turned on. The first summary appears on your dashboard.", + "error": "Turning it on did not work. Give it another try in a moment." + }, + "unavailable": { + "title": "This instance’s provider is not answering right now.", + "body": "HealthLog is fully useful without the plain-language read. You can add a provider of your own later under Settings." + } }, "steps": { "who": "Who this record is for", diff --git a/messages/es.json b/messages/es.json index e5fddd66f..8470fe08f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4553,7 +4553,18 @@ "ladderOauthBody": "Conecta una cuenta de ChatGPT — sin coste por token, pero los términos del nivel de consumo pueden usar el contenido para entrenamiento, así que es una elección deliberada, nunca la opción por defecto para tus datos de salud.", "keylessLine": "HealthLog es plenamente útil sin IA. Tus paneles, gráficos, tendencias y exportaciones funcionan — la IA solo añade la lectura en lenguaje claro por encima.", "setupCta": "Configúralo (un minuto aprox.)", - "sharedKeyNote": "Esta instancia ya ofrece un proveedor de IA compartido, así que los insights funcionan para ti ahora mismo — sin configuración." + "sharedKeyNote": "Esta instancia ya ofrece un proveedor de IA compartido, así que los insights funcionan para ti ahora mismo — sin configuración.", + "offer": { + "title": "Esta instancia ya puede escribir tu resumen en lenguaje claro.", + "body": "El operador ofrece un proveedor que funcionó por última vez en las últimas 24 horas. Con un toque activas el lenguaje claro; tus datos irán entonces a ese único proveedor y a nadie más.", + "cta": "Activar el lenguaje claro", + "granted": "Activado. El primer resumen aparecerá en tu panel.", + "error": "No se pudo activar. Vuelve a intentarlo en un momento." + }, + "unavailable": { + "title": "El proveedor de esta instancia no responde en este momento.", + "body": "HealthLog es plenamente útil sin el lenguaje claro. Más adelante puedes añadir tu propio proveedor en los ajustes." + } }, "steps": { "who": "Para quién es este registro", diff --git a/messages/fr.json b/messages/fr.json index 1640aa7e9..64947f346 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4553,7 +4553,18 @@ "ladderOauthBody": "Reliez un compte ChatGPT — sans coût par jeton, mais les conditions du niveau grand public peuvent utiliser le contenu pour l'entraînement : c'est donc un choix délibéré, jamais l'option par défaut pour vos données de santé.", "keylessLine": "HealthLog est pleinement utile sans IA. Vos tuiles, graphiques, tendances et exports fonctionnent tous — l'IA n'ajoute que la lecture en langage clair par-dessus.", "setupCta": "Configurez-le (environ une minute)", - "sharedKeyNote": "Cette instance fournit déjà un fournisseur d'IA partagé : les insights fonctionnent pour vous dès maintenant — aucune configuration nécessaire." + "sharedKeyNote": "Cette instance fournit déjà un fournisseur d'IA partagé : les insights fonctionnent pour vous dès maintenant — aucune configuration nécessaire.", + "offer": { + "title": "Cette instance peut rédiger votre résumé en langage clair.", + "body": "L’exploitant met à disposition un fournisseur qui a fonctionné au cours des dernières 24 heures. Une seule touche active le langage clair ; vos données partent alors vers ce seul fournisseur et vers personne d’autre.", + "cta": "Activer le langage clair", + "granted": "Activé. Le premier résumé apparaîtra sur votre tableau de bord.", + "error": "L’activation n’a pas abouti. Réessayez dans un instant." + }, + "unavailable": { + "title": "Le fournisseur de cette instance ne répond pas pour le moment.", + "body": "HealthLog reste pleinement utile sans le langage clair. Vous pourrez ajouter votre propre fournisseur plus tard dans les paramètres." + } }, "steps": { "who": "À qui appartient ce dossier", diff --git a/messages/it.json b/messages/it.json index d965b3827..eb1f044cc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4553,7 +4553,18 @@ "ladderOauthBody": "Collega un account ChatGPT — nessun costo per token, ma i termini del piano consumer possono usare i contenuti per l'addestramento, quindi è una scelta deliberata, mai l'impostazione predefinita per i tuoi dati sanitari.", "keylessLine": "HealthLog è pienamente utile anche senza IA. Le tue schede, i grafici, i trend e le esportazioni funzionano tutti — l'IA aggiunge solo la lettura in linguaggio chiaro.", "setupCta": "Configuralo (circa un minuto)", - "sharedKeyNote": "Questa istanza fornisce già un provider IA condiviso, quindi gli insight funzionano per te fin da subito — nessuna configurazione necessaria." + "sharedKeyNote": "Questa istanza fornisce già un provider IA condiviso, quindi gli insight funzionano per te fin da subito — nessuna configurazione necessaria.", + "offer": { + "title": "Questa istanza può già scrivere il tuo riepilogo in parole semplici.", + "body": "Il gestore mette a disposizione un fornitore che ha funzionato nelle ultime 24 ore. Con un tocco attivi le parole semplici; i tuoi dati andranno allora a quell’unico fornitore e a nessun altro.", + "cta": "Attiva le parole semplici", + "granted": "Attivato. Il primo riepilogo comparirà sulla tua dashboard.", + "error": "L’attivazione non è riuscita. Riprova tra un momento." + }, + "unavailable": { + "title": "Il fornitore di questa istanza al momento non risponde.", + "body": "HealthLog resta pienamente utile senza le parole semplici. Potrai aggiungere un fornitore tuo più avanti nelle impostazioni." + } }, "steps": { "who": "A chi appartiene questa cartella", diff --git a/messages/ko.json b/messages/ko.json index 4dabc847b..4b7a5e376 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4553,7 +4553,18 @@ "ladderOauthBody": "ChatGPT 계정을 연결해요. 토큰당 비용은 없지만 소비자 등급 약관은 내용을 학습에 쓸 수 있어서, 건강 데이터에는 기본값이 아니라 신중히 고를 선택이에요.", "keylessLine": "AI 없이도 HealthLog는 충분히 쓸모 있어요. 타일, 차트, 추이, 내보내기 모두 그대로 작동하고 — AI는 그 위에 쉬운 말 해설을 더할 뿐이에요.", "setupCta": "설정하기 (1분 정도)", - "sharedKeyNote": "이 인스턴스에는 이미 공용 AI 제공자가 있어서 지금 바로 인사이트를 쓸 수 있어요 — 따로 설정할 게 없어요." + "sharedKeyNote": "이 인스턴스에는 이미 공용 AI 제공자가 있어서 지금 바로 인사이트를 쓸 수 있어요 — 따로 설정할 게 없어요.", + "offer": { + "title": "이 인스턴스가 요약을 쉬운 말로 써 줄 수 있습니다.", + "body": "운영자가 제공하는 공급자가 지난 24시간 안에 마지막으로 정상 동작했습니다. 한 번만 누르면 쉬운 말 요약이 켜지며, 그러면 데이터는 그 하나의 공급자에게만 전달되고 다른 어느 곳에도 가지 않습니다.", + "cta": "쉬운 말 요약 켜기", + "granted": "켜졌습니다. 첫 요약은 대시보드에 표시됩니다.", + "error": "켜지지 않았습니다. 잠시 후에 다시 시도해 주세요." + }, + "unavailable": { + "title": "이 인스턴스의 공급자가 지금은 응답하지 않습니다.", + "body": "쉬운 말 요약 없이도 HealthLog는 완전히 쓸모가 있습니다. 나중에 설정에서 직접 공급자를 등록할 수 있습니다." + } }, "steps": { "who": "이 기록의 대상", diff --git a/messages/pl.json b/messages/pl.json index 9769c0838..6ea9f7dd9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4553,7 +4553,18 @@ "ladderOauthBody": "Podłącz konto ChatGPT — bez kosztu za token, ale warunki poziomu konsumenckiego mogą wykorzystywać treści do treningu, więc to świadomy wybór, nigdy domyślny dla danych o zdrowiu.", "keylessLine": "HealthLog jest w pełni użyteczny bez AI. Twoje kafelki, wykresy, trendy i eksporty działają — AI dokłada tylko odczyt prostym językiem.", "setupCta": "Skonfiguruj (około minuty)", - "sharedKeyNote": "Ta instancja udostępnia już wspólnego dostawcę AI, więc insighty działają dla Ciebie od razu — bez konfiguracji." + "sharedKeyNote": "Ta instancja udostępnia już wspólnego dostawcę AI, więc insighty działają dla Ciebie od razu — bez konfiguracji.", + "offer": { + "title": "Ta instancja potrafi już napisać twoje podsumowanie zwykłym językiem.", + "body": "Operator udostępnia dostawcę, który działał po raz ostatni w ciągu ostatnich 24 godzin. Jedno dotknięcie włącza zwykły język; twoje dane trafią wtedy do tego jednego dostawcy i do nikogo więcej.", + "cta": "Włącz zwykły język", + "granted": "Włączone. Pierwsze podsumowanie pojawi się na pulpicie.", + "error": "Włączenie się nie powiodło. Spróbuj ponownie za chwilę." + }, + "unavailable": { + "title": "Dostawca tej instancji w tej chwili nie odpowiada.", + "body": "HealthLog jest w pełni użyteczny bez zwykłego języka. Później możesz dodać w ustawieniach własnego dostawcę." + } }, "steps": { "who": "Dla kogo jest ta kartoteka", diff --git a/src/components/onboarding/__tests__/done-screen.test.tsx b/src/components/onboarding/__tests__/done-screen.test.tsx index 628d87837..eaa9ad780 100644 --- a/src/components/onboarding/__tests__/done-screen.test.tsx +++ b/src/components/onboarding/__tests__/done-screen.test.tsx @@ -12,15 +12,52 @@ import { I18nProvider } from "@/lib/i18n/context"; */ const aiProviderState = vi.hoisted(() => ({ - data: undefined as { managedBy?: string | null } | undefined, + data: undefined as + | { + managedBy?: string | null; + serverProviderHealth?: string; + serverProviderOffer?: boolean; + serverProviderConsent?: boolean; + } + | undefined, })); +/** + * The consent grant is a mutation, and this suite renders to static markup + * (the house convention — there is no jsdom in this repo), so the click + * itself cannot be dispatched. What IS worth proving is the request the + * button would send, so every `useMutation` registration is captured here + * and its `mutationFn` invoked directly. + */ +const mutations = vi.hoisted( + () => + [] as Array<{ + mutationKey?: readonly unknown[]; + mutationFn?: () => Promise; + }>, +); + vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: aiProviderState.data }), - useMutation: () => ({ mutate: () => {}, isPending: false }), + useMutation: (options?: { + mutationKey?: readonly unknown[]; + mutationFn?: () => Promise; + }) => { + if (options) mutations.push(options); + return { mutate: () => {}, mutateAsync: async () => {}, isPending: false }; + }, useQueryClient: () => ({ invalidateQueries: async () => {} }), })); +const apiFetchRaw = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({ + ...(await importOriginal>()), + apiFetchRaw, +})); + +const toastError = vi.hoisted(() => vi.fn()); +vi.mock("sonner", () => ({ toast: { error: toastError } })); + vi.mock("next/navigation", () => ({ useRouter: () => ({ push: () => {}, replace: () => {} }), })); @@ -110,11 +147,133 @@ describe(" AI panel", () => { expect(render("both")).toContain('href="/settings/access"'); expect(render("me")).not.toContain('href="/settings/access"'); }); +}); - it("shows the shared-key note only when the operator key serves the user", () => { - aiProviderState.data = { managedBy: "user" }; - expect(render()).not.toContain('data-slot="onboarding-ai-shared-key"'); - aiProviderState.data = { managedBy: "server" }; - expect(render()).toContain('data-slot="onboarding-ai-shared-key"'); +/** + * v1.38.19 (wave D) — the shared provider, offered only when it works. + * + * The card used to read `managedBy === "server"` and promise that insights + * "work for you right now". That is a presence read: it knew a key was + * configured, never that it worked, and it said nothing about the consent + * receipt the egress actually needs — so a fresh account followed the + * promise straight into a `consent.ai.required` refusal. On 2026-09-11 the + * operator's own instance was answering HTTP 500 while the screen kept + * making that promise. + * + * Four variants now, and only one of them has a button. + */ +function grantMutation() { + return mutations.find((m) => + (m.mutationKey ?? []).some( + (part) => typeof part === "string" && part.includes("consent"), + ), + ); +} + +describe(" shared-provider offer", () => { + it("offers one tap when the server says the provider is known to work", () => { + aiProviderState.data = { + managedBy: "server", + serverProviderHealth: "healthy", + serverProviderOffer: true, + serverProviderConsent: false, + }; + const html = render(); + expect(html).toContain('data-slot="onboarding-ai-offer"'); + expect(html).toContain('data-slot="onboarding-ai-offer-grant"'); + // The unproven promise is gone from this branch. + expect(html).not.toContain('data-slot="onboarding-ai-shared-key"'); + expect(html).not.toContain('data-slot="onboarding-ai-unavailable"'); + }); + + it.each(["unhealthy", "unknown"] as const)( + "says the provider is not answering on %s, and offers no button", + (health) => { + aiProviderState.data = { + managedBy: "server", + serverProviderHealth: health, + serverProviderOffer: false, + serverProviderConsent: false, + }; + const html = render(); + expect(html).toContain('data-slot="onboarding-ai-unavailable"'); + expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"'); + expect(html).not.toContain('data-slot="onboarding-ai-shared-key"'); + // The rest of the panel is untouched in every branch. + expect(html).toContain('data-slot="onboarding-ai-keyless"'); + expect(html).toContain('href="/settings/ai"'); + }, + ); + + it("keeps the shared-key note for the one state it was ever true of", () => { + aiProviderState.data = { + managedBy: "server", + serverProviderHealth: "healthy", + serverProviderOffer: false, + serverProviderConsent: true, + }; + const html = render(); + expect(html).toContain('data-slot="onboarding-ai-shared-key"'); + expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"'); + expect(html).not.toContain('data-slot="onboarding-ai-unavailable"'); + }); + + it("leaves a user with their own provider the setup link and nothing else", () => { + aiProviderState.data = { + managedBy: "user", + serverProviderHealth: "healthy", + serverProviderOffer: false, + serverProviderConsent: false, + }; + const html = render(); + expect(html).not.toContain('data-slot="onboarding-ai-offer"'); + expect(html).not.toContain('data-slot="onboarding-ai-unavailable"'); + expect(html).not.toContain('data-slot="onboarding-ai-shared-key"'); + expect(html).toContain('href="/settings/ai"'); + }); + + it("says nothing at all while the status is still in flight", () => { + aiProviderState.data = undefined; + const html = render(); + expect(html).not.toContain('data-slot="onboarding-ai-offer"'); + expect(html).not.toContain('data-slot="onboarding-ai-unavailable"'); + }); + + it("grants through the affirmative web intent — the only path that may lift a revocation", async () => { + mutations.length = 0; + apiFetchRaw.mockReset(); + apiFetchRaw.mockResolvedValue({ ok: true }); + aiProviderState.data = { + managedBy: "server", + serverProviderHealth: "healthy", + serverProviderOffer: true, + serverProviderConsent: false, + }; + render(); + const grant = grantMutation(); + expect(grant).toBeDefined(); + await grant!.mutationFn!(); + expect(apiFetchRaw).toHaveBeenCalledWith( + "/api/consent/ai/web", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ intent: "affirmative" }), + }), + ); + }); + + it("a refused grant throws rather than reporting a consent nobody recorded", async () => { + mutations.length = 0; + apiFetchRaw.mockReset(); + apiFetchRaw.mockResolvedValue({ ok: false, status: 429 }); + aiProviderState.data = { + managedBy: "server", + serverProviderHealth: "healthy", + serverProviderOffer: true, + serverProviderConsent: false, + }; + render(); + const grant = grantMutation(); + await expect(grant!.mutationFn!()).rejects.toThrow(); }); }); diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index 53b78ca92..799a06daf 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { CheckCircle2, Compass, @@ -24,7 +25,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useAccountSwitch } from "@/hooks/use-account-switch"; import { setTourReferrer } from "@/components/onboarding/tour-launcher"; import { queryKeys } from "@/lib/query-keys"; -import { apiGet } from "@/lib/api/api-fetch"; +import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage"; import type { OnboardingStateDto } from "@/lib/onboarding/needs"; @@ -33,6 +34,15 @@ import { accountLabel } from "@/lib/sharing/account-access-view"; interface AiProviderStatus { /** Origin of the provider that would serve this user, if any. */ managedBy?: "user" | "local" | "server" | null; + /** + * v1.38.19 (wave D) — the instance-wide tri-state. Fail closed: `unknown` + * is treated exactly like `unhealthy`, here and everywhere. + */ + serverProviderHealth?: "healthy" | "unhealthy" | "unknown"; + /** Whether the shared provider may honestly be offered in one tap. */ + serverProviderOffer?: boolean; + /** Whether this user already holds an active `ai_full` / `ai_coach` receipt. */ + serverProviderConsent?: boolean; } /** @@ -101,10 +111,21 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) { router.push("/"); } - // The shared-key note is the ONLY honest divergence in the panel: on a - // deployment that ships an operator key, insights already work for the - // user, so we say so plainly instead of pushing a BYOK/local setup they - // don't need. Presence-only read; no key material is exposed. + // ── v1.38.19 (wave D) — the shared provider, offered only when it works ── + // + // This block used to be one line: `managedBy === "server"` painted a note + // reading "insights work for you right now — no setup needed". Both halves + // were wrong. `managedBy` is a PRESENCE read — it knows the operator + // configured a key, never that the key answers — and "no setup needed" + // skipped the consent receipt `consent-guard.ts` demands before any health + // data reaches `admin-openai` / `admin-codex`, so the person who believed + // the note walked into a `consent.ai.required` refusal on their first + // briefing. On 2026-09-11 the operator's own instance was answering HTTP + // 500 from its OAuth proxy while this note kept promising otherwise. + // + // The server decides now, and the four branches below only READ it. The + // rest of the panel — sample, ladder, the "fully useful without AI" line, + // the setup link — is identical in every branch; nothing here is a gate. const { data: aiProvider } = useQuery({ queryKey: queryKeys.userAiProvider(), queryFn: async () => { @@ -112,7 +133,58 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) { }, enabled: !!user, }); - const sharedKeyServes = aiProvider?.managedBy === "server"; + + // The tap's own outcome, held locally so the line changes the moment the + // receipt is minted rather than a refetch later. + const [justGranted, setJustGranted] = useState(false); + const queryClient = useQueryClient(); + const grantConsent = useMutation({ + mutationKey: queryKeys.aiConsentReceipt("ai_full"), + mutationFn: async () => { + // `intent: "affirmative"` marks this as the user's own consent act — + // the only web path allowed to supersede an earlier withdrawal. The + // silent mount heal posts no body and deliberately cannot. + const res = await apiFetchRaw("/api/consent/ai/web", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ intent: "affirmative" }), + }); + if (!res.ok) throw new Error("consent grant failed"); + }, + onSuccess: async () => { + setJustGranted(true); + await queryClient.invalidateQueries({ + queryKey: queryKeys.userAiProvider(), + }); + await queryClient.invalidateQueries({ + queryKey: queryKeys.aiConsentReceipt("ai_full"), + }); + }, + onError: () => { + // The button stays. A failed grant is a failed grant; the screen must + // not read as though consent were on file. + toast.error(t("onboarding.ai.offer.error")); + }, + }); + + const operatorProvides = aiProvider?.managedBy === "server"; + const consentOnFile = + justGranted || aiProvider?.serverProviderConsent === true; + const offerShared = + operatorProvides && + !consentOnFile && + aiProvider?.serverProviderOffer === true; + // Only a health verdict paints the "not answering" card. When the offer is + // refused for a reason that is not health — the operator switched the + // assistant surfaces off, or this is a managed profile whose provider is + // the operator's by definition — the panel falls back to its neutral form + // and claims nothing either way. + const sharedUnavailable = + operatorProvides && + !consentOnFile && + !offerShared && + (aiProvider?.serverProviderHealth === "unhealthy" || + aiProvider?.serverProviderHealth === "unknown"); return (
)} - {sharedKeyServes ? ( + {consentOnFile && operatorProvides ? (

- {t("onboarding.ai.sharedKeyNote")} + {justGranted + ? t("onboarding.ai.offer.granted") + : t("onboarding.ai.sharedKeyNote")}

) : null} + {offerShared ? ( +
+

+ {t("onboarding.ai.offer.title")} +

+

+ {t("onboarding.ai.offer.body")} +

+ +
+ ) : null} + + {sharedUnavailable ? ( +
+

+ {t("onboarding.ai.unavailable.title")} +

+

+ {t("onboarding.ai.unavailable.body")} +

+
+ ) : null} +

{t("onboarding.ai.ladderTitle")}