diff --git a/src/client/components/CandidateCard.tsx b/src/client/components/CandidateCard.tsx
index 254d483..e1b198f 100644
--- a/src/client/components/CandidateCard.tsx
+++ b/src/client/components/CandidateCard.tsx
@@ -1,3 +1,4 @@
+import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
@@ -34,6 +35,7 @@ export function CandidateCard({
onStatusChange,
}: CandidateCardProps) {
const unit = useWeightUnit();
+ const currency = useCurrency();
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
const openConfirmDeleteCandidate = useUIStore(
(s) => s.openConfirmDeleteCandidate,
@@ -42,14 +44,74 @@ export function CandidateCard({
const openExternalLink = useUIStore((s) => s.openExternalLink);
return (
-
+
+
);
}
diff --git a/src/client/components/CategoryHeader.tsx b/src/client/components/CategoryHeader.tsx
index 33ee346..1556895 100644
--- a/src/client/components/CategoryHeader.tsx
+++ b/src/client/components/CategoryHeader.tsx
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
+import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
@@ -23,6 +24,7 @@ export function CategoryHeader({
itemCount,
}: CategoryHeaderProps) {
const unit = useWeightUnit();
+ const currency = useCurrency();
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(name);
const [editIcon, setEditIcon] = useState(icon);
@@ -87,7 +89,7 @@ export function CategoryHeader({
{name}
{itemCount} {itemCount === 1 ? "item" : "items"} ·{" "}
- {formatWeight(totalWeight, unit)} · {formatPrice(totalCost)}
+ {formatWeight(totalWeight, unit)} · {formatPrice(totalCost, currency)}
{!isUncategorized && (
diff --git a/src/client/components/ItemCard.tsx b/src/client/components/ItemCard.tsx
index d11bd02..6c634e7 100644
--- a/src/client/components/ItemCard.tsx
+++ b/src/client/components/ItemCard.tsx
@@ -1,7 +1,9 @@
+import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
+import { ClassificationBadge } from "./ClassificationBadge";
interface ItemCardProps {
id: number;
@@ -13,6 +15,8 @@ interface ItemCardProps {
imageFilename: string | null;
productUrl?: string | null;
onRemove?: () => void;
+ classification?: string;
+ onClassificationCycle?: () => void;
}
export function ItemCard({
@@ -25,8 +29,11 @@ export function ItemCard({
imageFilename,
productUrl,
onRemove,
+ classification,
+ onClassificationCycle,
}: ItemCardProps) {
const unit = useWeightUnit();
+ const currency = useCurrency();
const openEditPanel = useUIStore((s) => s.openEditPanel);
const openExternalLink = useUIStore((s) => s.openExternalLink);
@@ -129,7 +136,7 @@ export function ItemCard({
)}
{priceCents != null && (
- {formatPrice(priceCents)}
+ {formatPrice(priceCents, currency)}
)}
@@ -140,6 +147,12 @@ export function ItemCard({
/>{" "}
{categoryName}
+ {classification && onClassificationCycle && (
+
+ )}
diff --git a/src/client/components/ItemPicker.tsx b/src/client/components/ItemPicker.tsx
index d1b4267..2a84493 100644
--- a/src/client/components/ItemPicker.tsx
+++ b/src/client/components/ItemPicker.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
+import { useCurrency } from "../hooks/useCurrency";
import { useItems } from "../hooks/useItems";
import { useSyncSetupItems } from "../hooks/useSetups";
import { useWeightUnit } from "../hooks/useWeightUnit";
@@ -22,6 +23,7 @@ export function ItemPicker({
const { data: items } = useItems();
const syncItems = useSyncSetupItems(setupId);
const unit = useWeightUnit();
+ const currency = useCurrency();
const [selectedIds, setSelectedIds] = useState>(new Set());
// Reset selected IDs when panel opens
@@ -121,7 +123,7 @@ export function ItemPicker({
item.priceCents != null &&
" · "}
{item.priceCents != null &&
- formatPrice(item.priceCents)}
+ formatPrice(item.priceCents, currency)}
))}
diff --git a/src/client/components/SetupCard.tsx b/src/client/components/SetupCard.tsx
index 8dd87da..7ca8440 100644
--- a/src/client/components/SetupCard.tsx
+++ b/src/client/components/SetupCard.tsx
@@ -1,4 +1,5 @@
import { Link } from "@tanstack/react-router";
+import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
@@ -18,6 +19,7 @@ export function SetupCard({
totalCost,
}: SetupCardProps) {
const unit = useWeightUnit();
+ const currency = useCurrency();
return (
- {formatPrice(totalCost)}
+ {formatPrice(totalCost, currency)}
diff --git a/src/client/components/ThreadCard.tsx b/src/client/components/ThreadCard.tsx
index a109753..8f583ab 100644
--- a/src/client/components/ThreadCard.tsx
+++ b/src/client/components/ThreadCard.tsx
@@ -1,4 +1,5 @@
import { useNavigate } from "@tanstack/react-router";
+import { useCurrency } from "../hooks/useCurrency";
import { formatPrice } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
@@ -22,10 +23,11 @@ function formatDate(iso: string): string {
function formatPriceRange(
min: number | null,
max: number | null,
+ currency: Parameters[1],
): string | null {
if (min == null && max == null) return null;
- if (min === max) return formatPrice(min);
- return `${formatPrice(min)} - ${formatPrice(max)}`;
+ if (min === max) return formatPrice(min, currency);
+ return `${formatPrice(min, currency)} - ${formatPrice(max, currency)}`;
}
export function ThreadCard({
@@ -40,9 +42,10 @@ export function ThreadCard({
categoryIcon,
}: ThreadCardProps) {
const navigate = useNavigate();
+ const currency = useCurrency();
const isResolved = status === "resolved";
- const priceRange = formatPriceRange(minPriceCents, maxPriceCents);
+ const priceRange = formatPriceRange(minPriceCents, maxPriceCents, currency);
return (
= {
- base: "#6366f1",
- worn: "#f59e0b",
- consumable: "#10b981",
+ base: "#6b7280",
+ worn: "#9ca3af",
+ consumable: "#d1d5db",
};
const CLASSIFICATION_LABELS: Record = {
@@ -109,29 +110,34 @@ function CustomTooltip({
);
}
-function SubtotalColumn({
+function LegendRow({
+ color,
label,
weight,
unit,
- color,
+ percent,
}: {
+ color: string;
label: string;
weight: number;
unit: WeightUnit;
- color?: string;
+ percent?: number;
}) {
return (
-
- {color && (
-
- )}
-
{label}
-
+
+
+ {label}
+
{formatWeight(weight, unit)}
+ {percent != null && (
+
+ {(percent * 100).toFixed(0)}%
+
+ )}
);
}
@@ -237,27 +243,39 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
- {/* Weight subtotals columns */}
-
-
+ 0 ? baseWeight / totalWeight : undefined}
/>
- 0 ? wornWeight / totalWeight : undefined}
/>
- 0 ? consumableWeight / totalWeight : undefined}
/>
-
+
+
+
+ Total
+
+ {formatWeight(totalWeight, unit)}
+
+
+
+
diff --git a/src/client/hooks/useCurrency.ts b/src/client/hooks/useCurrency.ts
new file mode 100644
index 0000000..3d868e1
--- /dev/null
+++ b/src/client/hooks/useCurrency.ts
@@ -0,0 +1,12 @@
+import type { Currency } from "../lib/formatters";
+import { useSetting } from "./useSettings";
+
+const VALID_CURRENCIES: Currency[] = ["USD", "EUR", "GBP", "JPY", "CAD", "AUD"];
+
+export function useCurrency(): Currency {
+ const { data } = useSetting("currency");
+ if (data && VALID_CURRENCIES.includes(data as Currency)) {
+ return data as Currency;
+ }
+ return "USD";
+}
diff --git a/src/client/lib/formatters.ts b/src/client/lib/formatters.ts
index bf75747..fdb3a1a 100644
--- a/src/client/lib/formatters.ts
+++ b/src/client/lib/formatters.ts
@@ -21,7 +21,25 @@ export function formatWeight(
}
}
-export function formatPrice(cents: number | null | undefined): string {
+export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CAD" | "AUD";
+
+const CURRENCY_SYMBOLS: Record = {
+ USD: "$",
+ EUR: "€",
+ GBP: "£",
+ JPY: "¥",
+ CAD: "CA$",
+ AUD: "A$",
+};
+
+export function formatPrice(
+ cents: number | null | undefined,
+ currency: Currency = "USD",
+): string {
if (cents == null) return "--";
- return `$${(cents / 100).toFixed(2)}`;
+ const symbol = CURRENCY_SYMBOLS[currency];
+ if (currency === "JPY") {
+ return `${symbol}${Math.round(cents / 100)}`;
+ }
+ return `${symbol}${(cents / 100).toFixed(2)}`;
}
diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts
index 4073734..c274bdd 100644
--- a/src/client/routeTree.gen.ts
+++ b/src/client/routeTree.gen.ts
@@ -9,11 +9,17 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
+import { Route as SettingsRouteImport } from './routes/settings'
import { Route as IndexRouteImport } from './routes/index'
import { Route as CollectionIndexRouteImport } from './routes/collection/index'
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
+const SettingsRoute = SettingsRouteImport.update({
+ id: '/settings',
+ path: '/settings',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -37,12 +43,14 @@ const SetupsSetupIdRoute = SetupsSetupIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/settings': typeof SettingsRoute
'/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection/': typeof CollectionIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/settings': typeof SettingsRoute
'/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection': typeof CollectionIndexRoute
@@ -50,18 +58,30 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/settings': typeof SettingsRoute
'/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection/': typeof CollectionIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/setups/$setupId' | '/threads/$threadId' | '/collection/'
+ fullPaths:
+ | '/'
+ | '/settings'
+ | '/setups/$setupId'
+ | '/threads/$threadId'
+ | '/collection/'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/setups/$setupId' | '/threads/$threadId' | '/collection'
+ to:
+ | '/'
+ | '/settings'
+ | '/setups/$setupId'
+ | '/threads/$threadId'
+ | '/collection'
id:
| '__root__'
| '/'
+ | '/settings'
| '/setups/$setupId'
| '/threads/$threadId'
| '/collection/'
@@ -69,6 +89,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ SettingsRoute: typeof SettingsRoute
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
CollectionIndexRoute: typeof CollectionIndexRoute
@@ -76,6 +97,13 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
+ '/settings': {
+ id: '/settings'
+ path: '/settings'
+ fullPath: '/settings'
+ preLoaderRoute: typeof SettingsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -109,6 +137,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ SettingsRoute: SettingsRoute,
SetupsSetupIdRoute: SetupsSetupIdRoute,
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
CollectionIndexRoute: CollectionIndexRoute,
diff --git a/src/client/routes/collection/index.tsx b/src/client/routes/collection/index.tsx
index 36e81a3..20bbef8 100644
--- a/src/client/routes/collection/index.tsx
+++ b/src/client/routes/collection/index.tsx
@@ -1,5 +1,6 @@
-import { createFileRoute, useNavigate } from "@tanstack/react-router";
-import { useMemo, useState } from "react";
+import { createFileRoute } from "@tanstack/react-router";
+import { AnimatePresence, motion } from "framer-motion";
+import { useMemo, useRef, useState } from "react";
import { z } from "zod";
import { CategoryFilterDropdown } from "../../components/CategoryFilterDropdown";
import { CategoryHeader } from "../../components/CategoryHeader";
@@ -7,12 +8,14 @@ import { CreateThreadModal } from "../../components/CreateThreadModal";
import { ItemCard } from "../../components/ItemCard";
import { SetupCard } from "../../components/SetupCard";
import { ThreadCard } from "../../components/ThreadCard";
-import { CollectionTabs } from "../../components/ThreadTabs";
import { useCategories } from "../../hooks/useCategories";
import { useItems } from "../../hooks/useItems";
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
import { useThreads } from "../../hooks/useThreads";
import { useTotals } from "../../hooks/useTotals";
+import { useCurrency } from "../../hooks/useCurrency";
+import { useWeightUnit } from "../../hooks/useWeightUnit";
+import { formatPrice, formatWeight } from "../../lib/formatters";
import { LucideIcon } from "../../lib/iconData";
import { useUIStore } from "../../stores/uiStore";
@@ -25,26 +28,43 @@ export const Route = createFileRoute("/collection/")({
component: CollectionPage,
});
+const TAB_ORDER = ["gear", "planning", "setups"] as const;
+
+const slideVariants = {
+ enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
+ center: { x: 0, opacity: 1 },
+ exit: (dir: number) => ({ x: `${dir * -15}%`, opacity: 0 }),
+};
+
function CollectionPage() {
const { tab } = Route.useSearch();
- const navigate = useNavigate();
+ const prevTab = useRef(tab);
- function handleTabChange(newTab: "gear" | "planning" | "setups") {
- navigate({ to: "/collection", search: { tab: newTab } });
- }
+ const direction =
+ TAB_ORDER.indexOf(tab) >= TAB_ORDER.indexOf(prevTab.current) ? 1 : -1;
+ prevTab.current = tab;
return (
-
-
-
- {tab === "gear" ? (
-
- ) : tab === "planning" ? (
-
- ) : (
-
- )}
-
+
+
+
+ {tab === "gear" ? (
+
+ ) : tab === "planning" ? (
+
+ ) : (
+
+ )}
+
+
);
}
@@ -53,6 +73,8 @@ function CollectionView() {
const { data: items, isLoading: itemsLoading } = useItems();
const { data: totals } = useTotals();
const { data: categories } = useCategories();
+ const unit = useWeightUnit();
+ const currency = useCurrency();
const openAddPanel = useUIStore((s) => s.openAddPanel);
const [searchText, setSearchText] = useState("");
@@ -169,8 +191,41 @@ function CollectionView() {
return (
<>
+ {/* Collection stats card */}
+ {totals?.global && (
+
+
+
+
+ Items
+
+ {totals.global.itemCount}
+
+
+
+
+ Total Weight
+
+ {formatWeight(totals.global.totalWeight, unit)}
+
+
+
+
+ Total Spent
+
+ {formatPrice(totals.global.totalCost, currency)}
+
+
+
+
+ )}
+
{/* Search/filter toolbar */}
-
+
-
- No items match your search
-
+
No items match your search
) : (
@@ -516,21 +569,46 @@ function SetupsView() {
{/* Empty state */}
{!isLoading && (!setups || setups.length === 0) && (
-
-
-
-
-
-
- No setups yet
+
+
+
+ Build your perfect loadout
-
- Create one to plan your loadout.
-
+
+
+
+ 1
+
+
+
Create a setup
+
+ Name your loadout for a specific trip or activity
+
+
+
+
+
+ 2
+
+
+
Add items
+
+ Pick gear from your collection to include in the setup
+
+
+
+
+
+ 3
+
+
+
Track weight
+
+ See weight breakdown and optimize your pack
+
+
+
+
)}
diff --git a/src/client/routes/index.tsx b/src/client/routes/index.tsx
index ef70f9d..c417147 100644
--- a/src/client/routes/index.tsx
+++ b/src/client/routes/index.tsx
@@ -4,6 +4,7 @@ import { useSetups } from "../hooks/useSetups";
import { useThreads } from "../hooks/useThreads";
import { useTotals } from "../hooks/useTotals";
import { useWeightUnit } from "../hooks/useWeightUnit";
+import { useCurrency } from "../hooks/useCurrency";
import { formatPrice, formatWeight } from "../lib/formatters";
export const Route = createFileRoute("/")({
@@ -15,6 +16,7 @@ function DashboardPage() {
const { data: threads } = useThreads(false);
const { data: setups } = useSetups();
const unit = useWeightUnit();
+ const currency = useCurrency();
const global = totals?.global;
const activeThreadCount = threads?.length ?? 0;
@@ -33,7 +35,7 @@ function DashboardPage() {
label: "Weight",
value: formatWeight(global?.totalWeight ?? null, unit),
},
- { label: "Cost", value: formatPrice(global?.totalCost ?? null) },
+ { label: "Cost", value: formatPrice(global?.totalCost ?? null, currency) },
]}
emptyText="Get started"
/>
diff --git a/src/client/routes/settings.tsx b/src/client/routes/settings.tsx
new file mode 100644
index 0000000..0aab7cf
--- /dev/null
+++ b/src/client/routes/settings.tsx
@@ -0,0 +1,104 @@
+import { createFileRoute, Link } from "@tanstack/react-router";
+import { useCurrency } from "../hooks/useCurrency";
+import { useUpdateSetting } from "../hooks/useSettings";
+import { useWeightUnit } from "../hooks/useWeightUnit";
+import type { Currency, WeightUnit } from "../lib/formatters";
+
+const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
+const CURRENCIES: { value: Currency; label: string }[] = [
+ { value: "USD", label: "$" },
+ { value: "EUR", label: "€" },
+ { value: "GBP", label: "£" },
+ { value: "JPY", label: "¥" },
+ { value: "CAD", label: "CA$" },
+ { value: "AUD", label: "A$" },
+];
+
+export const Route = createFileRoute("/settings")({
+ component: SettingsPage,
+});
+
+function SettingsPage() {
+ const unit = useWeightUnit();
+ const currency = useCurrency();
+ const updateSetting = useUpdateSetting();
+
+ return (
+
+
+
+ ← Back
+
+
Settings
+
+
+
+
+
+
Weight Unit
+
+ Choose the unit used to display weights across the app
+
+
+
+ {UNITS.map((u) => (
+
+ updateSetting.mutate({
+ key: "weightUnit",
+ value: u,
+ })
+ }
+ className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
+ unit === u
+ ? "bg-white text-gray-700 shadow-sm font-medium"
+ : "text-gray-400 hover:text-gray-600"
+ }`}
+ >
+ {u}
+
+ ))}
+
+
+
+
+
+
+
+
Currency
+
+ Changes the currency symbol displayed. This does not convert
+ values.
+
+
+
+ {CURRENCIES.map((c) => (
+
+ updateSetting.mutate({
+ key: "currency",
+ value: c.value,
+ })
+ }
+ className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
+ currency === c.value
+ ? "bg-white text-gray-700 shadow-sm font-medium"
+ : "text-gray-400 hover:text-gray-600"
+ }`}
+ >
+ {c.label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/client/routes/setups/$setupId.tsx b/src/client/routes/setups/$setupId.tsx
index fa06048..2602716 100644
--- a/src/client/routes/setups/$setupId.tsx
+++ b/src/client/routes/setups/$setupId.tsx
@@ -1,7 +1,6 @@
-import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { CategoryHeader } from "../../components/CategoryHeader";
-import { ClassificationBadge } from "../../components/ClassificationBadge";
import { ItemCard } from "../../components/ItemCard";
import { ItemPicker } from "../../components/ItemPicker";
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
@@ -11,6 +10,7 @@ import {
useSetup,
useUpdateItemClassification,
} from "../../hooks/useSetups";
+import { useCurrency } from "../../hooks/useCurrency";
import { useWeightUnit } from "../../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../../lib/formatters";
import { LucideIcon } from "../../lib/iconData";
@@ -22,6 +22,7 @@ export const Route = createFileRoute("/setups/$setupId")({
function SetupDetailPage() {
const { setupId } = Route.useParams();
const unit = useWeightUnit();
+ const currency = useCurrency();
const navigate = useNavigate();
const numericId = Number(setupId);
const { data: setup, isLoading } = useSetup(numericId);
@@ -107,9 +108,18 @@ function SetupDetailPage() {
{/* Setup-specific sticky bar */}
-
- {setup.name}
-
+
+
+ ←
+
+
+ {setup.name}
+
+
{itemCount}{" "}
@@ -123,7 +133,7 @@ function SetupDetailPage() {
- {formatPrice(totalCost)}
+ {formatPrice(totalCost, currency)}
{" "}
cost
@@ -219,32 +229,27 @@ function SetupDetailPage() {
/>
{categoryItems.map((item) => (
-
-
removeItem.mutate(item.id)}
- />
-
-
- updateClassification.mutate({
- itemId: item.id,
- classification: nextClassification(
- item.classification,
- ),
- })
- }
- />
-
-
+
removeItem.mutate(item.id)}
+ classification={item.classification}
+ onClassificationCycle={() =>
+ updateClassification.mutate({
+ itemId: item.id,
+ classification: nextClassification(
+ item.classification,
+ ),
+ })
+ }
+ />
))}