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