feat(i18n): extract strings from navigation, dialogs, onboarding, settings, and login
- Add useTranslation() to TopNav, BottomTabBar, FabMenu, UserMenu - Internationalize ConfirmDialog, AuthPromptModal, ExternalLinkDialog - Extract all onboarding flow strings (Welcome, HobbyPicker, ItemBrowser, Review, Done) - Internationalize settings page (weight unit, currency, API keys, import/export) - Internationalize login page and root error boundary - All dialogs in __root.tsx use t() for UI chrome Phase 34, Plan 02 (core navigation and global UI)
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
|
||||
export function AuthPromptModal() {
|
||||
const { t } = useTranslation();
|
||||
const showAuthPrompt = useUIStore((s) => s.showAuthPrompt);
|
||||
const closeAuthPrompt = useUIStore((s) => s.closeAuthPrompt);
|
||||
|
||||
@@ -18,10 +20,10 @@ export function AuthPromptModal() {
|
||||
/>
|
||||
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Join GearBox
|
||||
{t("auth.joinGearBox")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
To manage your own collection, sign in or sign up.
|
||||
{t("auth.signInDescription")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link
|
||||
@@ -29,14 +31,14 @@ export function AuthPromptModal() {
|
||||
className="w-full text-center px-4 py-2.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
onClick={closeAuthPrompt}
|
||||
>
|
||||
Sign in
|
||||
{t("auth.signIn")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="w-full text-center px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
onClick={closeAuthPrompt}
|
||||
>
|
||||
Create account
|
||||
{t("auth.createAccount")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, useMatchRoute } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
@@ -26,6 +27,7 @@ function TabItemWrapper({ icon, label, isActive }: TabItemProps) {
|
||||
}
|
||||
|
||||
export function BottomTabBar() {
|
||||
const { t } = useTranslation();
|
||||
const { data: auth } = useAuth();
|
||||
const isAuthenticated = !!auth?.user;
|
||||
const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
|
||||
@@ -46,7 +48,7 @@ export function BottomTabBar() {
|
||||
<div className="flex justify-around">
|
||||
{/* Home tab — always a Link */}
|
||||
<Link to="/">
|
||||
<TabItemWrapper icon="house" label="Home" isActive={isHome} />
|
||||
<TabItemWrapper icon="house" label={t("nav.home")} isActive={isHome} />
|
||||
</Link>
|
||||
|
||||
{/* Collection tab — Link if authenticated, button if anonymous */}
|
||||
@@ -54,7 +56,7 @@ export function BottomTabBar() {
|
||||
<Link to="/collection">
|
||||
<TabItemWrapper
|
||||
icon="package"
|
||||
label="Collection"
|
||||
label={t("nav.collection")}
|
||||
isActive={isCollection}
|
||||
/>
|
||||
</Link>
|
||||
@@ -62,7 +64,7 @@ export function BottomTabBar() {
|
||||
<button type="button" onClick={openAuthPrompt}>
|
||||
<TabItemWrapper
|
||||
icon="package"
|
||||
label="Collection"
|
||||
label={t("nav.collection")}
|
||||
isActive={isCollection}
|
||||
/>
|
||||
</button>
|
||||
@@ -71,17 +73,17 @@ export function BottomTabBar() {
|
||||
{/* Setups tab — Link if authenticated, button if anonymous */}
|
||||
{isAuthenticated ? (
|
||||
<Link to="/setups">
|
||||
<TabItemWrapper icon="layers" label="Setups" isActive={isSetups} />
|
||||
<TabItemWrapper icon="layers" label={t("nav.setups")} isActive={isSetups} />
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" onClick={openAuthPrompt}>
|
||||
<TabItemWrapper icon="layers" label="Setups" isActive={isSetups} />
|
||||
<TabItemWrapper icon="layers" label={t("nav.setups")} isActive={isSetups} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Search tab — always a button, opens CatalogSearchOverlay */}
|
||||
<button type="button" onClick={() => openCatalogSearch("collection")}>
|
||||
<TabItemWrapper icon="search" label="Search" isActive={false} />
|
||||
<TabItemWrapper icon="search" label={t("nav.search")} isActive={false} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDeleteItem, useItems } from "../hooks/useItems";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
|
||||
export function ConfirmDialog() {
|
||||
const { t } = useTranslation();
|
||||
const confirmDeleteItemId = useUIStore((s) => s.confirmDeleteItemId);
|
||||
const closeConfirmDelete = useUIStore((s) => s.closeConfirmDelete);
|
||||
const deleteItem = useDeleteItem();
|
||||
@@ -30,12 +32,10 @@ export function ConfirmDialog() {
|
||||
/>
|
||||
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Delete Item
|
||||
{t("confirm.deleteItem")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-medium">{itemName}</span>? This action cannot be
|
||||
undone.
|
||||
{t("confirm.deleteItemMessage", { name: itemName })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
@@ -43,7 +43,7 @@ export function ConfirmDialog() {
|
||||
onClick={closeConfirmDelete}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -51,7 +51,7 @@ export function ConfirmDialog() {
|
||||
disabled={deleteItem.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
{deleteItem.isPending ? "Deleting..." : "Delete"}
|
||||
{deleteItem.isPending ? t("actions.deleting") : t("actions.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
|
||||
export function ExternalLinkDialog() {
|
||||
const { t } = useTranslation();
|
||||
const externalLinkUrl = useUIStore((s) => s.externalLinkUrl);
|
||||
const closeExternalLink = useUIStore((s) => s.closeExternalLink);
|
||||
|
||||
@@ -35,9 +37,9 @@ export function ExternalLinkDialog() {
|
||||
/>
|
||||
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
You are about to leave GearBox
|
||||
{t("externalLink.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-1">You will be redirected to:</p>
|
||||
<p className="text-sm text-gray-600 mb-1">{t("externalLink.redirectMessage")}</p>
|
||||
<p className="text-sm text-gray-600 break-all mb-6">
|
||||
{externalLinkUrl}
|
||||
</p>
|
||||
@@ -47,14 +49,14 @@ export function ExternalLinkDialog() {
|
||||
onClick={closeExternalLink}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
Continue
|
||||
{t("actions.continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Package, Plus, Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
|
||||
interface FabMenuProps {
|
||||
@@ -15,6 +16,7 @@ interface MenuItem {
|
||||
}
|
||||
|
||||
export function FabMenu({ isSetupsPage }: FabMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const fabMenuOpen = useUIStore((s) => s.fabMenuOpen);
|
||||
const openFabMenu = useUIStore((s) => s.openFabMenu);
|
||||
const closeFabMenu = useUIStore((s) => s.closeFabMenu);
|
||||
@@ -26,12 +28,12 @@ export function FabMenu({ isSetupsPage }: FabMenuProps) {
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
label: "Add to Collection",
|
||||
label: t("fab.addToCollection"),
|
||||
icon: <Package className="w-5 h-5 text-gray-600" />,
|
||||
onClick: () => openCatalogSearch("collection"),
|
||||
},
|
||||
{
|
||||
label: "Start New Thread",
|
||||
label: t("fab.startNewThread"),
|
||||
icon: <Search className="w-5 h-5 text-gray-600" />,
|
||||
onClick: () => openCatalogSearch("thread"),
|
||||
},
|
||||
@@ -39,7 +41,7 @@ export function FabMenu({ isSetupsPage }: FabMenuProps) {
|
||||
|
||||
if (isSetupsPage) {
|
||||
menuItems.push({
|
||||
label: "New Setup",
|
||||
label: t("fab.newSetup"),
|
||||
icon: <Plus className="w-5 h-5 text-gray-600" />,
|
||||
onClick: () => {
|
||||
closeFabMenu();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, useMatchRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
@@ -42,6 +43,7 @@ function NavLinkOrButton({
|
||||
}
|
||||
|
||||
export function TopNav() {
|
||||
const { t } = useTranslation();
|
||||
const { data: auth } = useAuth();
|
||||
const isAuthenticated = !!auth?.user;
|
||||
const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
|
||||
@@ -82,7 +84,7 @@ export function TopNav() {
|
||||
isAuthenticated={isAuthenticated}
|
||||
onAuthPrompt={openAuthPrompt}
|
||||
>
|
||||
Home
|
||||
{t("nav.home")}
|
||||
</NavLinkOrButton>
|
||||
<NavLinkOrButton
|
||||
to="/collection"
|
||||
@@ -91,7 +93,7 @@ export function TopNav() {
|
||||
isAuthenticated={isAuthenticated}
|
||||
onAuthPrompt={openAuthPrompt}
|
||||
>
|
||||
Collection
|
||||
{t("nav.collection")}
|
||||
</NavLinkOrButton>
|
||||
<NavLinkOrButton
|
||||
to="/setups"
|
||||
@@ -100,7 +102,7 @@ export function TopNav() {
|
||||
isAuthenticated={isAuthenticated}
|
||||
onAuthPrompt={openAuthPrompt}
|
||||
>
|
||||
Setups
|
||||
{t("nav.setups")}
|
||||
</NavLinkOrButton>
|
||||
</nav>
|
||||
|
||||
@@ -124,7 +126,7 @@ export function TopNav() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
}}
|
||||
placeholder="Search catalog..."
|
||||
placeholder={t("nav.searchPlaceholder")}
|
||||
className="bg-gray-50 border border-gray-200 rounded-lg pl-9 pr-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-200 focus:border-gray-300 w-48 lg:w-64 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@@ -137,7 +139,7 @@ export function TopNav() {
|
||||
to="/login"
|
||||
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
{t("auth.signIn")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth, useLogout } from "../hooks/useAuth";
|
||||
import { usePublicProfile } from "../hooks/useProfile";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
|
||||
export function UserMenu() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { logout } = useLogout();
|
||||
@@ -34,7 +36,7 @@ export function UserMenu() {
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Profile"
|
||||
alt={t("nav.profile")}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
@@ -53,7 +55,7 @@ export function UserMenu() {
|
||||
size={16}
|
||||
className="text-gray-400"
|
||||
/>
|
||||
Profile
|
||||
{t("nav.profile")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
@@ -61,7 +63,7 @@ export function UserMenu() {
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<LucideIcon name="settings" size={16} className="text-gray-400" />
|
||||
Settings
|
||||
{t("nav.settings")}
|
||||
</Link>
|
||||
<div className="border-t border-gray-100 my-1" />
|
||||
<button
|
||||
@@ -73,7 +75,7 @@ export function UserMenu() {
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<LucideIcon name="log-out" size={16} className="text-gray-400" />
|
||||
Sign out
|
||||
{t("auth.signOut")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
|
||||
interface OnboardingDoneProps {
|
||||
@@ -9,6 +10,7 @@ export function OnboardingDone({
|
||||
itemsCreated: _itemsCreated,
|
||||
onFinish,
|
||||
}: OnboardingDoneProps) {
|
||||
const { t } = useTranslation("onboarding");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-8">
|
||||
<div className="max-w-2xl text-center">
|
||||
@@ -20,18 +22,17 @@ export function OnboardingDone({
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
You're all set!
|
||||
{t("done.title")}
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
Your collection is ready. Browse the catalog anytime to discover more
|
||||
gear.
|
||||
{t("done.subtitle")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFinish}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Start exploring
|
||||
{t("done.cta")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HOBBIES } from "@/shared/hobbyConfig";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { HobbyCard } from "./HobbyCard";
|
||||
|
||||
interface OnboardingHobbyPickerProps {
|
||||
@@ -12,14 +13,15 @@ export function OnboardingHobbyPicker({
|
||||
onToggleHobby,
|
||||
onContinue,
|
||||
}: OnboardingHobbyPickerProps) {
|
||||
const { t } = useTranslation("onboarding");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-8">
|
||||
<div className="max-w-2xl text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
What are you into?
|
||||
{t("hobby.title")}
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
Pick one or more — we'll show you popular gear for each.
|
||||
{t("hobby.subtitle")}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4 mb-8">
|
||||
{HOBBIES.map((hobby) => (
|
||||
@@ -39,7 +41,7 @@ export function OnboardingHobbyPicker({
|
||||
disabled={selectedHobbies.length === 0}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Continue
|
||||
{t("hobby.continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTagsForHobbies } from "@/shared/hobbyConfig";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopularItems } from "../../hooks/useOnboarding";
|
||||
import { SelectableItemCard } from "./SelectableItemCard";
|
||||
|
||||
@@ -17,6 +18,7 @@ export function OnboardingItemBrowser({
|
||||
onContinue,
|
||||
onSkip,
|
||||
}: OnboardingItemBrowserProps) {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const tags = getTagsForHobbies(selectedHobbies);
|
||||
const { data: items, isLoading } = usePopularItems(tags);
|
||||
|
||||
@@ -48,11 +50,12 @@ export function OnboardingItemBrowser({
|
||||
<div className="flex flex-col items-center min-h-screen px-8 py-16">
|
||||
<div className="max-w-5xl w-full text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Popular gear for{" "}
|
||||
{selectedHobbies.length === 1 ? selectedHobbies[0] : "your hobbies"}
|
||||
{selectedHobbies.length === 1
|
||||
? t("items.title", { hobby: selectedHobbies[0] })
|
||||
: t("items.titleMultiple")}
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
Tap items you already own. We'll add them to your collection.
|
||||
{t("items.subtitle")}
|
||||
</p>
|
||||
|
||||
{isLoading && (
|
||||
@@ -64,11 +67,10 @@ export function OnboardingItemBrowser({
|
||||
{!isLoading && !hasItems && (
|
||||
<div className="py-12 text-center">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No gear cataloged yet
|
||||
{t("items.noCatalog")}
|
||||
</h2>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
We're still building our catalog for this hobby. You can skip this
|
||||
step and add gear manually later.
|
||||
{t("items.noCatalogDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -105,8 +107,7 @@ export function OnboardingItemBrowser({
|
||||
onClick={onContinue}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Review {selectedItemIds.size}{" "}
|
||||
{selectedItemIds.size === 1 ? "item" : "items"}
|
||||
{t("items.reviewCount", { count: selectedItemIds.size })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -114,7 +115,7 @@ export function OnboardingItemBrowser({
|
||||
onClick={onSkip}
|
||||
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Skip this step
|
||||
{t("common:actions.skipStep")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
|
||||
interface ReviewItem {
|
||||
@@ -23,6 +24,7 @@ export function OnboardingReview({
|
||||
onSkip,
|
||||
isSubmitting,
|
||||
}: OnboardingReviewProps) {
|
||||
const { t } = useTranslation("onboarding");
|
||||
// Group by category
|
||||
const grouped = new Map<string, ReviewItem[]>();
|
||||
for (const item of items) {
|
||||
@@ -35,12 +37,12 @@ export function OnboardingReview({
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-8">
|
||||
<div className="max-w-2xl w-full text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Your starting collection
|
||||
{t("review.title")}
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
{items.length > 0
|
||||
? `${items.length} ${items.length === 1 ? "item" : "items"} ready to add`
|
||||
: "No items selected — you can always add gear later from the catalog."}
|
||||
? t("review.itemsReady", { count: items.length })
|
||||
: t("review.noItemsSelected")}
|
||||
</p>
|
||||
|
||||
{items.length > 0 && (
|
||||
@@ -101,7 +103,7 @@ export function OnboardingReview({
|
||||
disabled={isSubmitting}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isSubmitting ? "Adding..." : "Add to my collection"}
|
||||
{isSubmitting ? t("review.adding") : t("review.addToCollection")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -109,7 +111,7 @@ export function OnboardingReview({
|
||||
onClick={onSkip}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Continue
|
||||
{t("common:actions.continue")}
|
||||
</button>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
@@ -118,7 +120,7 @@ export function OnboardingReview({
|
||||
onClick={onSkip}
|
||||
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Skip this step
|
||||
{t("common:actions.skipStep")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface OnboardingWelcomeProps {
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingWelcome({ onContinue }: OnboardingWelcomeProps) {
|
||||
const { t } = useTranslation("onboarding");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-8">
|
||||
<div className="max-w-2xl text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Welcome to GearBox
|
||||
{t("welcome.title")}
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8 leading-relaxed">
|
||||
Tell us what you're into, and we'll help you set up your collection
|
||||
with gear that people actually use.
|
||||
{t("welcome.subtitle")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Let's go
|
||||
{t("welcome.cta")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useRouter,
|
||||
} from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Toaster } from "sonner";
|
||||
import "../app.css";
|
||||
import { AddToCollectionModal } from "../components/AddToCollectionModal";
|
||||
@@ -33,6 +34,7 @@ export const Route = createRootRoute({
|
||||
|
||||
function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
@@ -53,12 +55,12 @@ function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Something went wrong
|
||||
{t("errors.somethingWentWrong")}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred"}
|
||||
: t("errors.unexpectedError")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -68,7 +70,7 @@ function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
|
||||
}}
|
||||
className="px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Try again
|
||||
{t("actions.tryAgain")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,6 +207,7 @@ function CandidateDeleteDialog({
|
||||
threadId: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const deleteCandidate = useDeleteCandidate(threadId);
|
||||
const { data: thread } = useThread(threadId);
|
||||
const candidate = thread?.candidates.find((c) => c.id === candidateId);
|
||||
@@ -227,12 +230,10 @@ function CandidateDeleteDialog({
|
||||
/>
|
||||
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Delete Candidate
|
||||
{t("confirm.deleteCandidate")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-medium">{candidateName}</span>? This action
|
||||
cannot be undone.
|
||||
{t("confirm.deleteCandidateMessage", { name: candidateName })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
@@ -240,7 +241,7 @@ function CandidateDeleteDialog({
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -248,7 +249,7 @@ function CandidateDeleteDialog({
|
||||
disabled={deleteCandidate.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
{deleteCandidate.isPending ? "Deleting..." : "Delete"}
|
||||
{deleteCandidate.isPending ? t("actions.deleting") : t("actions.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,6 +268,7 @@ function ResolveDialog({
|
||||
onClose: () => void;
|
||||
onResolved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolveThread = useResolveThread();
|
||||
const { data: thread } = useThread(threadId);
|
||||
const candidate = thread?.candidates.find((c) => c.id === candidateId);
|
||||
@@ -290,11 +292,10 @@ function ResolveDialog({
|
||||
/>
|
||||
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Pick Winner
|
||||
{t("confirm.pickWinner")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Pick <span className="font-medium">{candidateName}</span> as the
|
||||
winner? This will add it to your collection and archive the thread.
|
||||
{t("confirm.pickWinnerMessage", { name: candidateName })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
@@ -302,7 +303,7 @@ function ResolveDialog({
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -310,7 +311,7 @@ function ResolveDialog({
|
||||
disabled={resolveThread.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
{resolveThread.isPending ? "Resolving..." : "Pick Winner"}
|
||||
{resolveThread.isPending ? t("actions.saving") : t("confirm.pickWinner")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
@@ -7,6 +8,7 @@ export const Route = createFileRoute("/login")({
|
||||
});
|
||||
|
||||
function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data: auth, isLoading } = useAuth();
|
||||
|
||||
@@ -19,7 +21,7 @@ function LoginPage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<p className="text-gray-500 text-sm">Loading...</p>
|
||||
<p className="text-gray-500 text-sm">{t("actions.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,11 +30,11 @@ function LoginPage() {
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-xl font-semibold text-gray-900 text-center mb-6">
|
||||
Sign in to GearBox
|
||||
{t("auth.signInToGearBox")}
|
||||
</h1>
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-6 space-y-4">
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
You will be redirected to sign in with your account.
|
||||
{t("auth.redirectDescription")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -41,7 +43,7 @@ function LoginPage() {
|
||||
}}
|
||||
className="w-full py-2 px-4 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
Sign In
|
||||
{t("auth.signIn")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useApiKeys,
|
||||
useAuth,
|
||||
@@ -27,6 +28,7 @@ export const Route = createFileRoute("/settings")({
|
||||
});
|
||||
|
||||
function ApiKeySection() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { data: keys } = useApiKeys();
|
||||
const createKey = useCreateApiKey();
|
||||
const deleteKey = useDeleteApiKey();
|
||||
@@ -42,16 +44,15 @@ function ApiKeySection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">API Keys</h3>
|
||||
<h3 className="text-sm font-medium text-gray-900">{t("apiKeys.title")}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
API keys allow programmatic access to GearBox (e.g., from Claude Desktop
|
||||
or scripts).
|
||||
{t("apiKeys.description")}
|
||||
</p>
|
||||
|
||||
{newKey && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-xs font-medium text-amber-800 mb-1">
|
||||
Copy this key now — it won't be shown again:
|
||||
{t("apiKeys.copyWarning")}
|
||||
</p>
|
||||
<code className="text-xs text-amber-900 break-all select-all">
|
||||
{newKey}
|
||||
@@ -61,7 +62,7 @@ function ApiKeySection() {
|
||||
onClick={() => setNewKey(null)}
|
||||
className="mt-2 block text-xs text-amber-700 hover:text-amber-900"
|
||||
>
|
||||
Dismiss
|
||||
{t("common:actions.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -69,7 +70,7 @@ function ApiKeySection() {
|
||||
<form onSubmit={handleCreate} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Key name (e.g., claude-desktop)"
|
||||
placeholder={t("apiKeys.namePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
@@ -80,7 +81,7 @@ function ApiKeySection() {
|
||||
disabled={createKey.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
Create
|
||||
{t("common:actions.create")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -102,7 +103,7 @@ function ApiKeySection() {
|
||||
onClick={() => deleteKey.mutate(key.id)}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
Revoke
|
||||
{t("common:actions.revoke")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -113,6 +114,7 @@ function ApiKeySection() {
|
||||
}
|
||||
|
||||
function ImportExportSection() {
|
||||
const { t } = useTranslation("settings");
|
||||
const exportItems = useExportItems();
|
||||
const importItems = useImportItems();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -142,9 +144,9 @@ function ImportExportSection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">Import / Export</h3>
|
||||
<h3 className="text-sm font-medium text-gray-900">{t("importExport.title")}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Export your gear collection as a CSV file, or import items from a CSV.
|
||||
{t("importExport.description")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -153,11 +155,11 @@ function ImportExportSection() {
|
||||
onClick={exportItems}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
Export CSV
|
||||
{t("importExport.export")}
|
||||
</button>
|
||||
|
||||
<label className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 hover:bg-gray-50 rounded-lg transition-colors cursor-pointer">
|
||||
{importItems.isPending ? "Importing..." : "Import CSV"}
|
||||
{importItems.isPending ? t("importExport.importing") : t("importExport.import")}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -179,12 +181,11 @@ function ImportExportSection() {
|
||||
>
|
||||
{importResult.imported > 0 && (
|
||||
<p className="font-medium">
|
||||
{importResult.imported} item
|
||||
{importResult.imported !== 1 ? "s" : ""} imported.
|
||||
{t("importExport.imported", { count: importResult.imported })}
|
||||
</p>
|
||||
)}
|
||||
{importResult.createdCategories.length > 0 && (
|
||||
<p>New categories: {importResult.createdCategories.join(", ")}</p>
|
||||
<p>{t("importExport.newCategories", { categories: importResult.createdCategories.join(", ") })}</p>
|
||||
)}
|
||||
{importResult.errors.map((err, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
|
||||
@@ -193,7 +194,7 @@ function ImportExportSection() {
|
||||
</p>
|
||||
))}
|
||||
{importResult.imported === 0 && importResult.errors.length === 0 && (
|
||||
<p>No items found in the CSV.</p>
|
||||
<p>{t("importExport.noItemsFound")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -228,6 +229,7 @@ function getSuggestedCurrency(): Currency | null {
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const { t } = useTranslation("settings");
|
||||
const unit = useWeightUnit();
|
||||
const { currency, showConversions } = useCurrency();
|
||||
const updateSetting = useUpdateSetting();
|
||||
@@ -245,9 +247,9 @@ function SettingsPage() {
|
||||
to="/"
|
||||
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
|
||||
>
|
||||
← Back
|
||||
← {t("common:actions.back")}
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Settings</h1>
|
||||
<h1 className="text-xl font-semibold text-gray-900">{t("title")}</h1>
|
||||
</div>
|
||||
|
||||
{showSuggestion && (
|
||||
@@ -279,9 +281,9 @@ function SettingsPage() {
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Weight Unit</h3>
|
||||
<h3 className="text-sm font-medium text-gray-900">{t("weightUnit.title")}</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Choose the unit used to display weights across the app
|
||||
{t("weightUnit.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
|
||||
@@ -312,10 +314,10 @@ function SettingsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
Market & Currency
|
||||
{t("currency.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Sets your market region and currency for price display
|
||||
{t("currency.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
|
||||
|
||||
Reference in New Issue
Block a user