From 275e159a0d879452493a2789a37dbe9c51ca97f2 Mon Sep 17 00:00:00 2001 From: omercelikdev Date: Tue, 21 Jul 2026 00:05:34 +0300 Subject: [PATCH] feat(onboarding): full-screen first-run welcome wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First launch showed only a small inline hint banner (plus, on macOS, the accessibility prompt), leaving Windows users with no guidance at all. Add a modern 3-step full-screen welcome: what QlipLab does → your summon shortcut (shown as a live kbd) → offer launch-on-login, then Get started. - WelcomeWizard.tsx: cross-platform, RTL-aware, i18n; pure welcomeFinishPatch / clampStep helpers extracted for testing - settingsStore: welcomeSeen (distinct from onboardingSeen so each retires independently; the wizard also retires the inline banner on finish) - App.tsx: gate the error-reporting opt-in behind welcomeSeen so the two full-screen surfaces never stack - i18n: 14 welcome.* keys across all 15 locales - 10 tests (pure logic + i18n render/interaction) Co-Authored-By: Claude Opus 4.8 --- src/App.tsx | 14 +- src/components/layout/WelcomeWizard.test.tsx | 130 ++++++++++++ src/components/layout/WelcomeWizard.tsx | 196 +++++++++++++++++++ src/i18n/locales/ar.json | 14 ++ src/i18n/locales/de.json | 14 ++ src/i18n/locales/en.json | 14 ++ src/i18n/locales/es.json | 14 ++ src/i18n/locales/fr.json | 14 ++ src/i18n/locales/hi.json | 14 ++ src/i18n/locales/it.json | 14 ++ src/i18n/locales/ja.json | 14 ++ src/i18n/locales/ko.json | 14 ++ src/i18n/locales/nl.json | 14 ++ src/i18n/locales/pl.json | 14 ++ src/i18n/locales/pt.json | 14 ++ src/i18n/locales/ru.json | 14 ++ src/i18n/locales/tr.json | 14 ++ src/i18n/locales/zh.json | 14 ++ src/stores/settingsStore.ts | 4 + 19 files changed, 551 insertions(+), 3 deletions(-) create mode 100644 src/components/layout/WelcomeWizard.test.tsx create mode 100644 src/components/layout/WelcomeWizard.tsx diff --git a/src/App.tsx b/src/App.tsx index c2835b8..d2b5eb2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { Splitter } from './components/layout/Splitter'; import { ResizeBorder } from './components/layout/ResizeBorder'; import { WindowControls } from './components/layout/WindowControls'; import { OnboardingBanner } from './components/layout/OnboardingBanner'; +import { WelcomeWizard } from './components/layout/WelcomeWizard'; import { AccessibilityBanner } from './components/layout/AccessibilityBanner'; import { CapturePausedBanner } from './components/layout/CapturePausedBanner'; import { HistoryList } from './components/history/HistoryList'; @@ -50,6 +51,7 @@ function App() { const { editorOpen: snippetEditorOpen } = useSnippetStore(); const showSidePanel = activeTab !== 'settings' && (previewOpen || snippetEditorOpen); const { hasSeenOptIn, loadSettings: loadFeedbackSettings } = useFeedbackStore(); + const welcomeSeen = useSettingsStore((s) => s.settings.welcomeSeen); const [isInitialized, setIsInitialized] = useState(false); const [showOptIn, setShowOptIn] = useState(false); // Restore the divider position the user dragged to last time. @@ -120,14 +122,15 @@ function App() { return () => window.removeEventListener('focus', onFocus); }, []); - // Show opt-in dialog on first run + // Show opt-in dialog on first run — but only after the welcome wizard is done, + // so the two full-screen surfaces never stack on top of each other. useEffect(() => { - if (isInitialized && !hasSeenOptIn) { + if (isInitialized && welcomeSeen && !hasSeenOptIn) { // Small delay to let the UI settle on first launch const timer = setTimeout(() => setShowOptIn(true), 500); return () => clearTimeout(timer); } - }, [isInitialized, hasSeenOptIn]); + }, [isInitialized, welcomeSeen, hasSeenOptIn]); // Listen for tray icon "Show" event from Rust useEffect(() => { @@ -308,6 +311,11 @@ function App() { setShowOptIn(false)} /> + + {/* First-run welcome. Sits above everything and gates the opt-in dialog. */} + + {isInitialized && !welcomeSeen && } + ); } diff --git a/src/components/layout/WelcomeWizard.test.tsx b/src/components/layout/WelcomeWizard.test.tsx new file mode 100644 index 0000000..3f834e9 --- /dev/null +++ b/src/components/layout/WelcomeWizard.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; + +// jsdom never "finishes" framer-motion's exit animation, so AnimatePresence +// mode="wait" would keep the next step unmounted. Render motion elements plainly +// and let AnimatePresence pass its children straight through. +vi.mock('framer-motion', async () => { + const React = await import('react'); + const passthrough = + (Tag: string) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ({ children, initial, animate, exit, transition, whileTap, whileHover, layout, ...rest }: any) => + React.createElement(Tag, rest, children); + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + AnimatePresence: ({ children }: any) => React.createElement(React.Fragment, null, children), + motion: new Proxy({}, { get: (_t, tag: string) => passthrough(tag) }), + }; +}); +import i18next from 'i18next'; +import { initReactI18next, I18nextProvider } from 'react-i18next'; +import en from '@/i18n/locales/en.json'; +import { useSettingsStore } from '@/stores/settingsStore'; +import { + WelcomeWizard, + welcomeFinishPatch, + clampStep, + WELCOME_STEPS, +} from './WelcomeWizard'; + +// A self-contained i18n instance so tests don't depend on app bootstrap. +const testI18n = i18next.createInstance(); +testI18n.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + resources: { en: { translation: en } }, + interpolation: { escapeValue: false }, +}); + +function renderWizard() { + return render( + + + + ); +} + +afterEach(cleanup); + +describe('welcomeFinishPatch', () => { + it('always retires the welcome and inline onboarding', () => { + expect(welcomeFinishPatch(true)).toMatchObject({ + welcomeSeen: true, + onboardingSeen: true, + }); + expect(welcomeFinishPatch(false)).toMatchObject({ + welcomeSeen: true, + onboardingSeen: true, + }); + }); + + it('carries the autostart choice through', () => { + expect(welcomeFinishPatch(true).launchOnLogin).toBe(true); + expect(welcomeFinishPatch(false).launchOnLogin).toBe(false); + }); +}); + +describe('clampStep', () => { + it('clamps below zero to the first step', () => { + expect(clampStep(-3)).toBe(0); + }); + it('clamps past the end to the last step', () => { + expect(clampStep(99)).toBe(WELCOME_STEPS - 1); + }); + it('passes valid indices through', () => { + expect(clampStep(1)).toBe(1); + }); +}); + +describe('WelcomeWizard', () => { + beforeEach(() => { + // A resolved stub so finishing never touches the Tauri store in tests. + useSettingsStore.setState({ updateSettings: vi.fn().mockResolvedValue(undefined) }); + }); + + it('opens on the first step', () => { + renderWizard(); + expect(screen.getByText(en['welcome.step1.title'])).toBeInTheDocument(); + }); + + it('advances through every step to the final CTA', () => { + renderWizard(); + fireEvent.click(screen.getByText(en['welcome.next'])); + expect(screen.getByText(en['welcome.step2.title'])).toBeInTheDocument(); + fireEvent.click(screen.getByText(en['welcome.next'])); + expect(screen.getByText(en['welcome.step3.title'])).toBeInTheDocument(); + // Last step swaps "Next" for the finish CTA. + expect(screen.getByText(en['welcome.getStarted'])).toBeInTheDocument(); + expect(screen.queryByText(en['welcome.next'])).not.toBeInTheDocument(); + }); + + it('persists the finish patch with autostart on by default', () => { + const patch = vi.fn().mockResolvedValue(undefined); + useSettingsStore.setState({ updateSettings: patch }); + renderWizard(); + fireEvent.click(screen.getByText(en['welcome.next'])); + fireEvent.click(screen.getByText(en['welcome.next'])); + fireEvent.click(screen.getByText(en['welcome.getStarted'])); + expect(patch).toHaveBeenCalledWith(welcomeFinishPatch(true)); + }); + + it('lets the user turn autostart off before finishing', () => { + const patch = vi.fn().mockResolvedValue(undefined); + useSettingsStore.setState({ updateSettings: patch }); + renderWizard(); + fireEvent.click(screen.getByText(en['welcome.next'])); + fireEvent.click(screen.getByText(en['welcome.next'])); + fireEvent.click(screen.getByText(en['welcome.step3.autostart'])); + fireEvent.click(screen.getByText(en['welcome.getStarted'])); + expect(patch).toHaveBeenCalledWith(welcomeFinishPatch(false)); + }); + + it('skips straight to finishing from any step', () => { + const patch = vi.fn().mockResolvedValue(undefined); + useSettingsStore.setState({ updateSettings: patch }); + renderWizard(); + fireEvent.click(screen.getByText(en['welcome.skip'])); + expect(patch).toHaveBeenCalledWith(welcomeFinishPatch(true)); + }); +}); diff --git a/src/components/layout/WelcomeWizard.tsx b/src/components/layout/WelcomeWizard.tsx new file mode 100644 index 0000000..375f848 --- /dev/null +++ b/src/components/layout/WelcomeWizard.tsx @@ -0,0 +1,196 @@ +import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Clipboard, + Command, + Rocket, + ChevronRight, + ChevronLeft, + CornerDownLeft, + ArrowUpDown, + Check, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useSettingsStore, type AppSettings } from '@/stores/settingsStore'; +import { formatShortcut } from '@/lib/formatShortcut'; +import { cn } from '@/lib/utils'; + +export const WELCOME_STEPS = 3; + +/** The settings written when the wizard finishes (completed or skipped). Pure so + * the persisted outcome can be unit-tested without rendering. */ +export function welcomeFinishPatch(autostart: boolean): Partial { + return { + welcomeSeen: true, + // The wizard already teaches the hints, so the inline banner is redundant. + onboardingSeen: true, + launchOnLogin: autostart, + }; +} + +/** Clamp a step index into the valid [0, WELCOME_STEPS - 1] range. */ +export function clampStep(step: number): number { + return Math.max(0, Math.min(WELCOME_STEPS - 1, step)); +} + +export function WelcomeWizard() { + const { t } = useTranslation(); + const settings = useSettingsStore((s) => s.settings); + const updateSettings = useSettingsStore((s) => s.updateSettings); + const [step, setStep] = useState(0); + const [autostart, setAutostart] = useState(true); + + const shortcut = formatShortcut(settings.globalShortcut); + const isLast = step === WELCOME_STEPS - 1; + + const finish = () => { + void updateSettings(welcomeFinishPatch(autostart)); + }; + + const next = () => { + if (isLast) finish(); + else setStep((s) => clampStep(s + 1)); + }; + const back = () => setStep((s) => clampStep(s - 1)); + + return ( + + {/* Skip */} +
+ +
+ + {/* Body */} +
+ + + {step === 0 && ( + <> + +

{t('welcome.step1.title')}

+

{t('welcome.step1.body')}

+ + )} + {step === 1 && ( + <> + +

{t('welcome.step2.title')}

+

{t('welcome.step2.body')}

+ {shortcut && ( +
+ + {t('welcome.summonLabel')} + + + {shortcut} + +
+ )} +
+ + +
+ + )} + {step === 2 && ( + <> + +

{t('welcome.step3.title')}

+

{t('welcome.step3.body')}

+ + + )} +
+
+
+ + {/* Footer */} +
+ + + {/* Progress dots */} +
+ {Array.from({ length: WELCOME_STEPS }).map((_, i) => ( + + ))} +
+ + +
+
+ ); +} + +function IconBubble({ icon: Icon }: { icon: React.ElementType }) { + return ( +
+ +
+ ); +} + +function MiniHint({ icon: Icon, text }: { icon: React.ElementType; text: string }) { + return ( +
+ + {text} +
+ ); +} diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 5a687df..e171cbf 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "Enter للصق في التطبيق السابق", "onboarding.hint.arrowKeys": "مفاتيح الأسهم للتنقل", "onboarding.hint.optionD": "Option+D لمقارنة عنصرين", + "welcome.skip": "تخطٍّ", + "welcome.next": "التالي", + "welcome.back": "رجوع", + "welcome.getStarted": "ابدأ", + "welcome.summonLabel": "اختصارك", + "welcome.step1.title": "مرحبًا بك في QlipLab", + "welcome.step1.body": "انسخ أي شيء — نصوصًا وأكوادًا وروابط وصورًا. كله يظهر هنا جاهزًا للصق.", + "welcome.step2.title": "افتحه من أي مكان", + "welcome.step2.body": "استدعِ QlipLab باختصارك مهما كان التطبيق الذي تستخدمه.", + "welcome.step2.enterHint": "اضغط Enter للصق في التطبيق الذي أتيت منه", + "welcome.step2.arrowHint": "استخدم مفاتيح الأسهم لاختيار عنصر", + "welcome.step3.title": "كل شيء جاهز", + "welcome.step3.body": "ينتظر QlipLab بهدوء في الخلفية — افتحه في أي وقت باختصارك.", + "welcome.step3.autostart": "تشغيل QlipLab عند تسجيل الدخول", "feedback.title": "الإبلاغ عن مشكلة", "feedback.issueType": "نوع المشكلة", "feedback.type.bug": "خلل", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 386e88f..bc82d55 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "Enter zum Einfügen in die vorherige App", "onboarding.hint.arrowKeys": "Pfeiltasten zur Navigation", "onboarding.hint.optionD": "Option+D um zwei Einträge zu vergleichen", + "welcome.skip": "Überspringen", + "welcome.next": "Weiter", + "welcome.back": "Zurück", + "welcome.getStarted": "Loslegen", + "welcome.summonLabel": "Dein Kürzel", + "welcome.step1.title": "Willkommen bei QlipLab", + "welcome.step1.body": "Kopiere alles — Text, Code, Links, Bilder. Alles landet hier, bereit zum Einfügen.", + "welcome.step2.title": "Von überall öffnen", + "welcome.step2.body": "Ruf QlipLab mit deinem Kürzel auf, egal in welcher App du gerade bist.", + "welcome.step2.enterHint": "Enter drücken, um in die vorherige App einzufügen", + "welcome.step2.arrowHint": "Mit den Pfeiltasten einen Eintrag auswählen", + "welcome.step3.title": "Alles bereit", + "welcome.step3.body": "QlipLab wartet leise im Hintergrund — öffne es jederzeit mit deinem Kürzel.", + "welcome.step3.autostart": "QlipLab beim Anmelden starten", "feedback.title": "Problem melden", "feedback.issueType": "Art des Problems", "feedback.type.bug": "Fehler", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b2e92a9..b83c25a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "Enter to paste to previous app", "onboarding.hint.arrowKeys": "Arrow keys to navigate", "onboarding.hint.optionD": "Option+D to compare two items", + "welcome.skip": "Skip", + "welcome.next": "Next", + "welcome.back": "Back", + "welcome.getStarted": "Get started", + "welcome.summonLabel": "Your shortcut", + "welcome.step1.title": "Welcome to QlipLab", + "welcome.step1.body": "Copy anything — text, code, links, images. It all lands here, ready to paste back.", + "welcome.step2.title": "Open from anywhere", + "welcome.step2.body": "Summon QlipLab with your shortcut, no matter which app you're in.", + "welcome.step2.enterHint": "Press Enter to paste into the app you came from", + "welcome.step2.arrowHint": "Use the arrow keys to pick a clip", + "welcome.step3.title": "You're all set", + "welcome.step3.body": "QlipLab waits quietly in the background — open it anytime with your shortcut.", + "welcome.step3.autostart": "Launch QlipLab at login", "feedback.title": "Report Issue", "feedback.issueType": "Issue Type", "feedback.type.bug": "Bug", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3990032..2f31736 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "Enter para pegar en la aplicación anterior", "onboarding.hint.arrowKeys": "Flechas para navegar", "onboarding.hint.optionD": "Option+D para comparar dos elementos", + "welcome.skip": "Omitir", + "welcome.next": "Siguiente", + "welcome.back": "Atrás", + "welcome.getStarted": "Empezar", + "welcome.summonLabel": "Tu atajo", + "welcome.step1.title": "Bienvenido a QlipLab", + "welcome.step1.body": "Copia lo que sea — texto, código, enlaces, imágenes. Todo llega aquí, listo para pegar.", + "welcome.step2.title": "Ábrelo desde cualquier lugar", + "welcome.step2.body": "Invoca QlipLab con tu atajo, estés en la app que estés.", + "welcome.step2.enterHint": "Pulsa Enter para pegar en la app de la que venías", + "welcome.step2.arrowHint": "Usa las flechas para elegir un elemento", + "welcome.step3.title": "Todo listo", + "welcome.step3.body": "QlipLab espera en segundo plano — ábrelo cuando quieras con tu atajo.", + "welcome.step3.autostart": "Iniciar QlipLab al iniciar sesión", "feedback.title": "Reportar problema", "feedback.issueType": "Tipo de problema", "feedback.type.bug": "Error", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ed22599..3ba72a7 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "Entrée pour coller dans l'application précédente", "onboarding.hint.arrowKeys": "Flèches pour naviguer", "onboarding.hint.optionD": "Option+D pour comparer deux éléments", + "welcome.skip": "Passer", + "welcome.next": "Suivant", + "welcome.back": "Retour", + "welcome.getStarted": "Commencer", + "welcome.summonLabel": "Votre raccourci", + "welcome.step1.title": "Bienvenue dans QlipLab", + "welcome.step1.body": "Copiez tout — texte, code, liens, images. Tout arrive ici, prêt à être collé.", + "welcome.step2.title": "Ouvrez depuis partout", + "welcome.step2.body": "Appelez QlipLab avec votre raccourci, quelle que soit l'application.", + "welcome.step2.enterHint": "Appuyez sur Entrée pour coller dans l'application d'origine", + "welcome.step2.arrowHint": "Utilisez les flèches pour choisir un élément", + "welcome.step3.title": "Tout est prêt", + "welcome.step3.body": "QlipLab attend discrètement en arrière-plan — ouvrez-le à tout moment avec votre raccourci.", + "welcome.step3.autostart": "Lancer QlipLab à l'ouverture de session", "feedback.title": "Signaler un problème", "feedback.issueType": "Type de problème", "feedback.type.bug": "Bug", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index e3051e4..f98028d 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "पिछले ऐप में पेस्ट करने के लिए Enter दबाएँ", "onboarding.hint.arrowKeys": "नेविगेट करने के लिए Arrow Keys", "onboarding.hint.optionD": "दो आइटम की तुलना के लिए Option+D", + "welcome.skip": "छोड़ें", + "welcome.next": "आगे", + "welcome.back": "पीछे", + "welcome.getStarted": "शुरू करें", + "welcome.summonLabel": "आपका शॉर्टकट", + "welcome.step1.title": "QlipLab में आपका स्वागत है", + "welcome.step1.body": "कुछ भी कॉपी करें — टेक्स्ट, कोड, लिंक, इमेज। सब यहाँ आ जाता है, पेस्ट करने के लिए तैयार।", + "welcome.step2.title": "कहीं से भी खोलें", + "welcome.step2.body": "किसी भी ऐप में हों, अपने शॉर्टकट से QlipLab बुलाएँ।", + "welcome.step2.enterHint": "जिस ऐप से आए, उसमें पेस्ट करने के लिए Enter दबाएँ", + "welcome.step2.arrowHint": "क्लिप चुनने के लिए ऐरो कीज़ का उपयोग करें", + "welcome.step3.title": "सब तैयार है", + "welcome.step3.body": "QlipLab पृष्ठभूमि में चुपचाप इंतज़ार करता है — अपने शॉर्टकट से कभी भी खोलें।", + "welcome.step3.autostart": "लॉगिन पर QlipLab शुरू करें", "feedback.title": "समस्या रिपोर्ट करें", "feedback.issueType": "समस्या प्रकार", "feedback.type.bug": "बग", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 7f1b098..1033034 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "Invio per incollare nell'app precedente", "onboarding.hint.arrowKeys": "Frecce per navigare", "onboarding.hint.optionD": "Option+D per confrontare due elementi", + "welcome.skip": "Salta", + "welcome.next": "Avanti", + "welcome.back": "Indietro", + "welcome.getStarted": "Inizia", + "welcome.summonLabel": "La tua scorciatoia", + "welcome.step1.title": "Benvenuto in QlipLab", + "welcome.step1.body": "Copia qualsiasi cosa — testo, codice, link, immagini. Tutto arriva qui, pronto da incollare.", + "welcome.step2.title": "Aprilo da ovunque", + "welcome.step2.body": "Richiama QlipLab con la tua scorciatoia, in qualsiasi app ti trovi.", + "welcome.step2.enterHint": "Premi Invio per incollare nell'app da cui provieni", + "welcome.step2.arrowHint": "Usa le frecce per scegliere un elemento", + "welcome.step3.title": "Tutto pronto", + "welcome.step3.body": "QlipLab aspetta in silenzio in background — aprilo quando vuoi con la tua scorciatoia.", + "welcome.step3.autostart": "Avvia QlipLab all'accesso", "feedback.title": "Segnala un problema", "feedback.issueType": "Tipo di problema", "feedback.type.bug": "Bug", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5ec94bf..914e1eb 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "Enter で前のアプリに貼り付け", "onboarding.hint.arrowKeys": "矢印キーで移動", "onboarding.hint.optionD": "Option+D で2つの項目を比較", + "welcome.skip": "スキップ", + "welcome.next": "次へ", + "welcome.back": "戻る", + "welcome.getStarted": "はじめる", + "welcome.summonLabel": "ショートカット", + "welcome.step1.title": "QlipLab へようこそ", + "welcome.step1.body": "テキスト、コード、リンク、画像 — 何でもコピーすれば、すべてここに集まり、すぐ貼り付けられます。", + "welcome.step2.title": "どこからでも開く", + "welcome.step2.body": "どのアプリを使っていても、ショートカットで QlipLab を呼び出せます。", + "welcome.step2.enterHint": "Enter で元のアプリに貼り付け", + "welcome.step2.arrowHint": "矢印キーでクリップを選択", + "welcome.step3.title": "準備完了", + "welcome.step3.body": "QlipLab はバックグラウンドで静かに待機 — ショートカットでいつでも開けます。", + "welcome.step3.autostart": "ログイン時に QlipLab を起動", "feedback.title": "問題を報告", "feedback.issueType": "問題の種類", "feedback.type.bug": "バグ", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index b60d268..5572ae4 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "Enter로 이전 앱에 붙여넣기", "onboarding.hint.arrowKeys": "방향키로 이동", "onboarding.hint.optionD": "Option+D로 두 항목 비교", + "welcome.skip": "건너뛰기", + "welcome.next": "다음", + "welcome.back": "뒤로", + "welcome.getStarted": "시작하기", + "welcome.summonLabel": "단축키", + "welcome.step1.title": "QlipLab에 오신 것을 환영합니다", + "welcome.step1.body": "텍스트, 코드, 링크, 이미지 — 무엇이든 복사하면 모두 여기에 모여 바로 붙여넣을 수 있어요.", + "welcome.step2.title": "어디서나 열기", + "welcome.step2.body": "어떤 앱에 있든 단축키로 QlipLab을 불러오세요.", + "welcome.step2.enterHint": "Enter를 눌러 이전 앱에 붙여넣기", + "welcome.step2.arrowHint": "화살표 키로 항목 선택", + "welcome.step3.title": "준비 완료", + "welcome.step3.body": "QlipLab은 백그라운드에서 조용히 대기해요 — 단축키로 언제든 열 수 있어요.", + "welcome.step3.autostart": "로그인 시 QlipLab 실행", "feedback.title": "문제 보고", "feedback.issueType": "유형", "feedback.type.bug": "버그", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index b265d36..8144c34 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "Enter om te plakken in de vorige app", "onboarding.hint.arrowKeys": "Pijltjestoetsen om te navigeren", "onboarding.hint.optionD": "Option+D om twee items te vergelijken", + "welcome.skip": "Overslaan", + "welcome.next": "Volgende", + "welcome.back": "Terug", + "welcome.getStarted": "Aan de slag", + "welcome.summonLabel": "Je sneltoets", + "welcome.step1.title": "Welkom bij QlipLab", + "welcome.step1.body": "Kopieer alles — tekst, code, links, afbeeldingen. Het komt allemaal hier terecht, klaar om te plakken.", + "welcome.step2.title": "Open vanaf overal", + "welcome.step2.body": "Roep QlipLab op met je sneltoets, in welke app je ook bent.", + "welcome.step2.enterHint": "Druk op Enter om te plakken in de app waar je vandaan kwam", + "welcome.step2.arrowHint": "Gebruik de pijltjestoetsen om een clip te kiezen", + "welcome.step3.title": "Helemaal klaar", + "welcome.step3.body": "QlipLab wacht rustig op de achtergrond — open het altijd met je sneltoets.", + "welcome.step3.autostart": "QlipLab starten bij inloggen", "feedback.title": "Probleem melden", "feedback.issueType": "Type probleem", "feedback.type.bug": "Bug", diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index e788c6d..d64c3e0 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "Enter, aby wkleić do poprzedniej aplikacji", "onboarding.hint.arrowKeys": "Strzałki do nawigacji", "onboarding.hint.optionD": "Option+D, aby porównać dwa elementy", + "welcome.skip": "Pomiń", + "welcome.next": "Dalej", + "welcome.back": "Wstecz", + "welcome.getStarted": "Rozpocznij", + "welcome.summonLabel": "Twój skrót", + "welcome.step1.title": "Witaj w QlipLab", + "welcome.step1.body": "Kopiuj cokolwiek — tekst, kod, linki, obrazy. Wszystko trafia tutaj, gotowe do wklejenia.", + "welcome.step2.title": "Otwieraj skądkolwiek", + "welcome.step2.body": "Przywołaj QlipLab swoim skrótem w dowolnej aplikacji.", + "welcome.step2.enterHint": "Naciśnij Enter, aby wkleić do aplikacji, z której przyszedłeś", + "welcome.step2.arrowHint": "Użyj strzałek, aby wybrać element", + "welcome.step3.title": "Wszystko gotowe", + "welcome.step3.body": "QlipLab czeka cicho w tle — otwórz go w każdej chwili swoim skrótem.", + "welcome.step3.autostart": "Uruchamiaj QlipLab przy logowaniu", "feedback.title": "Zgłoś problem", "feedback.issueType": "Typ problemu", "feedback.type.bug": "Błąd", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 07fce85..60d80bc 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "Enter para colar no app anterior", "onboarding.hint.arrowKeys": "Setas para navegar", "onboarding.hint.optionD": "Option+D para comparar dois itens", + "welcome.skip": "Pular", + "welcome.next": "Avançar", + "welcome.back": "Voltar", + "welcome.getStarted": "Começar", + "welcome.summonLabel": "Seu atalho", + "welcome.step1.title": "Boas-vindas ao QlipLab", + "welcome.step1.body": "Copie qualquer coisa — texto, código, links, imagens. Tudo chega aqui, pronto para colar.", + "welcome.step2.title": "Abra de qualquer lugar", + "welcome.step2.body": "Chame o QlipLab com seu atalho, não importa em qual app você esteja.", + "welcome.step2.enterHint": "Pressione Enter para colar no app de onde você veio", + "welcome.step2.arrowHint": "Use as setas para escolher um item", + "welcome.step3.title": "Tudo pronto", + "welcome.step3.body": "O QlipLab espera silenciosamente em segundo plano — abra quando quiser com seu atalho.", + "welcome.step3.autostart": "Iniciar o QlipLab ao fazer login", "feedback.title": "Reportar Problema", "feedback.issueType": "Tipo do Problema", "feedback.type.bug": "Bug", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e85640c..42d8610 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "Enter — вставить в предыдущее приложение", "onboarding.hint.arrowKeys": "Стрелки для навигации", "onboarding.hint.optionD": "Option+D — сравнить два элемента", + "welcome.skip": "Пропустить", + "welcome.next": "Далее", + "welcome.back": "Назад", + "welcome.getStarted": "Начать", + "welcome.summonLabel": "Ваше сочетание клавиш", + "welcome.step1.title": "Добро пожаловать в QlipLab", + "welcome.step1.body": "Копируйте что угодно — текст, код, ссылки, картинки. Всё попадёт сюда и будет готово к вставке.", + "welcome.step2.title": "Открывайте откуда угодно", + "welcome.step2.body": "Вызывайте QlipLab сочетанием клавиш в любом приложении.", + "welcome.step2.enterHint": "Нажмите Enter, чтобы вставить в приложение, откуда пришли", + "welcome.step2.arrowHint": "Используйте стрелки, чтобы выбрать элемент", + "welcome.step3.title": "Всё готово", + "welcome.step3.body": "QlipLab тихо ждёт в фоне — откройте его в любой момент сочетанием клавиш.", + "welcome.step3.autostart": "Запускать QlipLab при входе в систему", "feedback.title": "Сообщить о проблеме", "feedback.issueType": "Тип проблемы", "feedback.type.bug": "Ошибка", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 0e15c14..5400e82 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "Enter ile önceki uygulamaya yapıştır", "onboarding.hint.arrowKeys": "Ok tuşları ile gezin", "onboarding.hint.optionD": "Option+D ile iki öğeyi karşılaştır", + "welcome.skip": "Atla", + "welcome.next": "İleri", + "welcome.back": "Geri", + "welcome.getStarted": "Başla", + "welcome.summonLabel": "Kısayolun", + "welcome.step1.title": "QlipLab'e hoş geldin", + "welcome.step1.body": "Ne kopyalarsan — metin, kod, bağlantı, görsel. Hepsi burada, geri yapıştırmaya hazır.", + "welcome.step2.title": "Her yerden aç", + "welcome.step2.body": "Hangi uygulamada olursan ol, kısayolunla QlipLab'i çağır.", + "welcome.step2.enterHint": "Geldiğin uygulamaya yapıştırmak için Enter'a bas", + "welcome.step2.arrowHint": "Bir öğe seçmek için ok tuşlarını kullan", + "welcome.step3.title": "Her şey hazır", + "welcome.step3.body": "QlipLab arka planda sessizce bekler — kısayolunla istediğin an aç.", + "welcome.step3.autostart": "QlipLab'i açılışta başlat", "feedback.title": "Sorun Bildir", "feedback.issueType": "Sorun Türü", "feedback.type.bug": "Hata", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1b1f8cd..a0abc99 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -311,6 +311,20 @@ "onboarding.hint.enterToPaste": "按 Enter 粘贴到上一个应用", "onboarding.hint.arrowKeys": "方向键导航", "onboarding.hint.optionD": "Option+D 对比两个项目", + "welcome.skip": "跳过", + "welcome.next": "下一步", + "welcome.back": "上一步", + "welcome.getStarted": "开始使用", + "welcome.summonLabel": "你的快捷键", + "welcome.step1.title": "欢迎使用 QlipLab", + "welcome.step1.body": "复制任何内容 —— 文本、代码、链接、图片,都会汇集到这里,随时粘贴。", + "welcome.step2.title": "随处唤起", + "welcome.step2.body": "无论在哪个应用中,都能用快捷键唤起 QlipLab。", + "welcome.step2.enterHint": "按 Enter 粘贴到你来时的应用", + "welcome.step2.arrowHint": "用方向键选择一条内容", + "welcome.step3.title": "一切就绪", + "welcome.step3.body": "QlipLab 会在后台静静等候 —— 随时用快捷键打开。", + "welcome.step3.autostart": "登录时启动 QlipLab", "feedback.title": "报告问题", "feedback.issueType": "问题类型", "feedback.type.bug": "Bug", diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index c969564..f085313 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -20,6 +20,9 @@ export interface AppSettings { expirationDays: number; // 0 = never snippetAutoExpand: boolean; onboardingSeen: boolean; + /** First-run welcome wizard completed (or skipped). Distinct from + * onboardingSeen (the inline hint banner) so each can retire independently. */ + welcomeSeen: boolean; globalShortcut: string; // primary, e.g. 'CommandOrControl+Shift+V' /** Optional second toggle shortcut. Empty string = unset. Defaults to * Ditto's Ctrl+` so migrants keep their muscle memory. */ @@ -47,6 +50,7 @@ export const DEFAULT_SETTINGS: AppSettings = { expirationDays: 0, snippetAutoExpand: true, onboardingSeen: false, + welcomeSeen: false, // Alt+Q collided with typing '@' on Turkish keyboards (AltGr+Q), so the // default is a safe combo; Ctrl+` mirrors Ditto as the secondary. globalShortcut: 'CommandOrControl+Shift+V',