Add onboarding flow for missing LLM key

This commit is contained in:
stefan-kp
2025-11-27 09:25:39 +01:00
parent 3afc75f78d
commit f7ee818360
3 changed files with 434 additions and 1 deletions
+251
View File
@@ -0,0 +1,251 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, ArrowRight, CheckCircle2, KeyRound, Languages, Sparkles } from "lucide-react";
import Header from "@/components/Header";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { SupportedLanguage } from "@/lib/i18n/translations";
const STEPS = 4;
export default function OnboardingPage() {
const router = useRouter();
const [step, setStep] = useState(0);
const [language, setLanguage] = useState<SupportedLanguage>("en");
const [apiKey, setApiKey] = useState("");
const [mounted, setMounted] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const storedKey = localStorage.getItem("gemini_api_key");
const storedLang = localStorage.getItem("chess_tutor_language");
if (storedLang) {
setLanguage(storedLang as SupportedLanguage);
}
if (storedKey) {
router.push("/");
return;
}
setMounted(true);
}, [router]);
const t = useTranslation(language);
useEffect(() => {
if (!mounted) return;
localStorage.setItem("chess_tutor_language", language);
}, [language, mounted]);
const progress = useMemo(() => ((step + 1) / STEPS) * 100, [step]);
const handleNext = () => {
setError("");
if (step < STEPS - 1) {
setStep(step + 1);
}
};
const handleBack = () => {
setError("");
if (step > 0) {
setStep(step - 1);
}
};
const handleFinish = () => {
const trimmed = apiKey.trim();
if (!trimmed) {
setError(t.start.apiKeyRequired);
return;
}
localStorage.setItem("gemini_api_key", trimmed);
localStorage.setItem("chess_tutor_language", language);
router.push("/");
};
if (!mounted) return null;
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900">
<Header language={language} />
<div className="max-w-4xl mx-auto px-4 py-10">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden border border-gray-100 dark:border-gray-700">
<div className="h-1 bg-gray-200 dark:bg-gray-700">
<div className="h-full bg-blue-600 transition-all duration-300" style={{ width: `${progress}%` }} />
</div>
<div className="p-6 md:p-10 space-y-8">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 uppercase tracking-wide font-semibold">
{t.onboarding.stepIndicator(step + 1, STEPS)}
</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mt-1">
{step === 0 && t.onboarding.welcome.title}
{step === 1 && t.onboarding.language.title}
{step === 2 && t.onboarding.value.title}
{step === 3 && t.onboarding.api.title}
</h1>
</div>
<div className="flex items-center gap-2">
{Array.from({ length: STEPS }).map((_, index) => (
<div
key={index}
className={`w-3 h-3 rounded-full transition-all duration-200 ${index <= step ? 'bg-blue-600 scale-105' : 'bg-gray-300 dark:bg-gray-600'}`}
/>
))}
</div>
</div>
{step === 0 && (
<div className="space-y-6">
<div className="flex flex-col md:flex-row items-center gap-8">
<div className="flex-1 space-y-4">
<div className="inline-flex items-center gap-2 px-3 py-1 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-sm font-medium">
<Sparkles size={16} />
{t.onboarding.welcome.subtitle}
</div>
<p className="text-xl text-gray-700 dark:text-gray-200 leading-relaxed">
{t.onboarding.welcome.claim}
</p>
</div>
<div className="flex-1">
<div className="relative overflow-hidden rounded-xl bg-gradient-to-br from-blue-600 via-indigo-600 to-purple-600 text-white shadow-lg">
<div className="p-6 space-y-3">
<p className="text-5xl"></p>
<p className="text-lg font-semibold">{t.start.title}</p>
<p className="text-sm text-blue-100">Gemini Stockfish Tactics</p>
</div>
<div className="absolute -right-10 -bottom-10 w-40 h-40 bg-white/10 rounded-full blur-3xl" />
</div>
</div>
</div>
</div>
)}
{step === 1 && (
<div className="space-y-6">
<p className="text-gray-700 dark:text-gray-300">{t.onboarding.language.description}</p>
<div className="flex flex-wrap gap-3">
{["en", "de", "fr", "it"].map((lang) => (
<button
key={lang}
onClick={() => setLanguage(lang as SupportedLanguage)}
className={`px-4 py-3 rounded-xl border text-sm font-semibold transition-all ${language === lang
? 'bg-blue-600 text-white border-blue-600 shadow-lg'
: 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:border-blue-400'}
`}
>
<span className="inline-flex items-center gap-2">
<Languages size={16} />
{lang.toUpperCase()}
</span>
</button>
))}
</div>
</div>
)}
{step === 2 && (
<div className="grid md:grid-cols-2 gap-6">
{t.onboarding.value.bullets.map((bullet, index) => (
<div key={index} className="flex items-start gap-3 bg-gray-50 dark:bg-gray-700/60 rounded-xl p-4 border border-gray-100 dark:border-gray-700">
<CheckCircle2 className="text-blue-600" size={20} />
<p className="text-gray-800 dark:text-gray-100 leading-relaxed">{bullet}</p>
</div>
))}
</div>
)}
{step === 3 && (
<div className="grid md:grid-cols-2 gap-8 items-start">
<div className="space-y-4">
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">{t.onboarding.api.description}</p>
<ul className="space-y-3 text-sm text-gray-700 dark:text-gray-300">
<li className="flex gap-2 items-start">
<KeyRound className="mt-0.5 text-blue-600" size={18} />
<span>{t.onboarding.api.storage}</span>
</li>
<li className="flex gap-2 items-start">
<KeyRound className="mt-0.5 text-blue-600" size={18} />
<span>{t.onboarding.api.serverUse}</span>
</li>
<li className="flex gap-2 items-start">
<KeyRound className="mt-0.5 text-blue-600" size={18} />
<span>{t.onboarding.api.costNote}</span>
</li>
<li className="flex gap-2 items-start">
<KeyRound className="mt-0.5 text-blue-600" size={18} />
<span>{t.onboarding.api.privacy}</span>
</li>
</ul>
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-blue-600 hover:underline font-semibold"
>
<ArrowRight size={16} /> {t.onboarding.api.getKey}
</a>
</div>
<div className="bg-gray-50 dark:bg-gray-700/60 rounded-xl p-6 border border-gray-100 dark:border-gray-700 space-y-4">
<label className="block text-sm font-semibold text-gray-800 dark:text-gray-100">
{t.onboarding.api.inputLabel}
</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t.onboarding.api.placeholder}
className="w-full p-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none"
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<p>There is no such thing as a free lunch LLM usage requires your own key.</p>
<p>{t.onboarding.api.storage}</p>
</div>
</div>
</div>
)}
<div className="flex justify-between items-center pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleBack}
disabled={step === 0}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${step === 0
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'}`}
>
<ArrowLeft size={18} /> {t.onboarding.actions.back}
</button>
<div className="flex items-center gap-3">
{step < STEPS - 1 && (
<button
onClick={handleNext}
className="inline-flex items-center gap-2 px-5 py-3 bg-blue-600 text-white rounded-lg font-semibold shadow-md hover:bg-blue-700 transition"
>
{t.onboarding.actions.next} <ArrowRight size={18} />
</button>
)}
{step === STEPS - 1 && (
<button
onClick={handleFinish}
className="inline-flex items-center gap-2 px-5 py-3 bg-green-600 text-white rounded-lg font-semibold shadow-md hover:bg-green-700 transition"
>
{t.onboarding.actions.finish}
</button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -27,7 +27,7 @@ export default function Home() {
// Check for API Key
const apiKey = localStorage.getItem("gemini_api_key");
if (!apiKey) {
router.push("/settings");
router.push("/onboarding");
return;
}
+182
View File
@@ -100,6 +100,40 @@ export interface Translations {
submit: string;
getKey: string;
};
// Onboarding
onboarding: {
welcome: {
title: string;
subtitle: string;
claim: string;
};
language: {
title: string;
description: string;
};
value: {
title: string;
bullets: string[];
};
api: {
title: string;
description: string;
inputLabel: string;
placeholder: string;
storage: string;
serverUse: string;
costNote: string;
privacy: string;
getKey: string;
};
actions: {
next: string;
back: string;
finish: string;
};
stepIndicator: (step: number, total: number) => string;
};
}
const en: Translations = {
@@ -187,6 +221,43 @@ const en: Translations = {
submit: 'Submit',
getKey: 'Get a free API key',
},
onboarding: {
welcome: {
title: 'Chess Tutor AI',
subtitle: 'Your personal coach to level up your chess.',
claim: 'Play, learn, and analyze with AI guidance powered by Gemini & Stockfish.',
},
language: {
title: 'Choose your language',
description: 'We will personalize your experience and future messages in the language you choose.',
},
value: {
title: 'What you get',
bullets: [
'Practice with personalities that match your style.',
'Import FEN or PGN to analyze games with live advice.',
'Blend Google Gemini creativity with Stockfish precision.',
'Keep your progress locally—resume anytime.',
],
},
api: {
title: 'Add your LLM API key',
description: 'We need your Gemini API key to generate coaching moves and explanations.',
inputLabel: 'Google Gemini API Key',
placeholder: 'AIzaSy...',
storage: 'Your key is stored in your browser and never shared.',
serverUse: 'We only send it to our server when making requests and never log it.',
costNote: 'LLM tokens are billed by Google, so you control the usage.',
privacy: 'We never use your key for anything outside this tutor.',
getKey: 'Get a free Gemini API key',
},
actions: {
next: 'Next',
back: 'Back',
finish: 'Save & start playing',
},
stepIndicator: (step: number, total: number) => `Step ${step} of ${total}`,
},
};
const de: Translations = {
@@ -274,6 +345,43 @@ const de: Translations = {
submit: 'Absenden',
getKey: 'Kostenlosen API-Schlüssel erhalten',
},
onboarding: {
welcome: {
title: 'Chess Tutor AI',
subtitle: 'Dein persönlicher Coach für den nächsten Spielzug.',
claim: 'Spiele, lerne und analysiere mit KI-Unterstützung von Gemini & Stockfish.',
},
language: {
title: 'Wähle deine Sprache',
description: 'Wir passen das Erlebnis und zukünftige Nachrichten an deine Sprache an.',
},
value: {
title: 'Deine Vorteile',
bullets: [
'Trainiere mit Persönlichkeiten, die zu deinem Stil passen.',
'Importiere FEN oder PGN und erhalte Live-Hinweise zur Analyse.',
'Kombiniere die Kreativität von Google Gemini mit der Präzision von Stockfish.',
'Speichere deinen Fortschritt lokal spiele jederzeit weiter.',
],
},
api: {
title: 'Füge deinen LLM-API-Schlüssel hinzu',
description: 'Wir benötigen deinen Gemini-API-Schlüssel, um Züge und Erklärungen zu generieren.',
inputLabel: 'Google Gemini API-Schlüssel',
placeholder: 'AIzaSy...',
storage: 'Dein Schlüssel bleibt im Browser gespeichert und wird nicht geteilt.',
serverUse: 'Wir senden ihn nur für Anfragen an unseren Server und protokollieren ihn nicht.',
costNote: 'LLM-Tokens werden von Google abgerechnet du behältst die Kontrolle.',
privacy: 'Wir nutzen deinen Schlüssel ausschließlich für diesen Tutor.',
getKey: 'Kostenlosen Gemini-Schlüssel holen',
},
actions: {
next: 'Weiter',
back: 'Zurück',
finish: 'Speichern & starten',
},
stepIndicator: (step: number, total: number) => `Schritt ${step} von ${total}`,
},
};
const fr: Translations = {
@@ -361,6 +469,43 @@ const fr: Translations = {
submit: 'Soumettre',
getKey: 'Obtenir une clé API gratuite',
},
onboarding: {
welcome: {
title: 'Chess Tutor AI',
subtitle: 'Votre coach personnel pour progresser aux échecs.',
claim: 'Jouez, apprenez et analysez avec laide de Gemini et Stockfish.',
},
language: {
title: 'Choisissez votre langue',
description: 'Nous personnaliserons votre expérience et les messages dans la langue choisie.',
},
value: {
title: 'Ce que vous obtenez',
bullets: [
'Entraînez-vous avec des personnalités adaptées à votre style.',
'Importez du FEN ou du PGN et analysez vos parties avec des conseils en direct.',
'Combinez la créativité de Google Gemini avec la précision de Stockfish.',
'Gardez vos progrès en local et reprenez votre partie à tout moment.',
],
},
api: {
title: 'Ajoutez votre clé API LLM',
description: 'Nous avons besoin de votre clé Gemini pour générer des coups et des explications.',
inputLabel: 'Clé API Google Gemini',
placeholder: 'AIzaSy...',
storage: 'Votre clé est stockée dans votre navigateur et nest jamais partagée.',
serverUse: 'Elle nest envoyée au serveur que pour les requêtes et nest jamais journalisée.',
costNote: 'Les jetons LLM sont facturés par Google, vous gardez le contrôle.',
privacy: 'Nous nutilisons votre clé que pour ce tuteur.',
getKey: 'Obtenir une clé Gemini gratuite',
},
actions: {
next: 'Suivant',
back: 'Retour',
finish: 'Enregistrer et commencer',
},
stepIndicator: (step: number, total: number) => `Étape ${step} sur ${total}`,
},
};
const it: Translations = {
@@ -448,6 +593,43 @@ const it: Translations = {
submit: 'Invia',
getKey: 'Ottieni chiave API gratuita',
},
onboarding: {
welcome: {
title: 'Chess Tutor AI',
subtitle: 'Il tuo coach personale per migliorare negli scacchi.',
claim: 'Gioca, impara e analizza con il supporto di Gemini e Stockfish.',
},
language: {
title: 'Scegli la tua lingua',
description: "Personalizzeremo l'esperienza e i messaggi nella lingua selezionata.",
},
value: {
title: 'Cosa ottieni',
bullets: [
'Allenati con personalità che si adattano al tuo stile.',
'Importa FEN o PGN e analizza le partite con suggerimenti in tempo reale.',
'Unisci la creatività di Google Gemini con la precisione di Stockfish.',
'Conserva i tuoi progressi in locale e riprendi quando vuoi.',
],
},
api: {
title: 'Aggiungi la tua chiave API LLM',
description: 'Abbiamo bisogno della tua chiave Gemini per generare mosse e spiegazioni.',
inputLabel: 'Chiave API Google Gemini',
placeholder: 'AIzaSy...',
storage: 'La chiave viene salvata nel tuo browser e non viene condivisa.',
serverUse: 'La inviamo solo per le richieste e non la registriamo mai.',
costNote: 'I token LLM sono fatturati da Google, quindi hai tu il controllo.',
privacy: 'Usiamo la tua chiave solo per questo tutor.',
getKey: 'Ottieni una chiave Gemini gratuita',
},
actions: {
next: 'Avanti',
back: 'Indietro',
finish: 'Salva e inizia',
},
stepIndicator: (step: number, total: number) => `Passo ${step} di ${total}`,
},
};
export const translations: Record<SupportedLanguage, Translations> = {