Add onboarding flow for missing LLM key
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 l’aide 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 n’est jamais partagée.',
|
||||
serverUse: 'Elle n’est envoyée au serveur que pour les requêtes et n’est jamais journalisée.',
|
||||
costNote: 'Les jetons LLM sont facturés par Google, vous gardez le contrôle.',
|
||||
privacy: 'Nous n’utilisons 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> = {
|
||||
|
||||
Reference in New Issue
Block a user