fix: wire catalog add buttons, fix Trans bold rendering, lint cleanup
Some checks failed
CI / ci (push) Failing after 1m44s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped

- CatalogSearchOverlay: replace handleAddStub with real openAddToCollection/openAddToThread routing based on catalogSearchMode
- ConfirmDialog + __root.tsx: swap t() for Trans component on deleteItemMessage, deleteCandidateMessage, pickWinnerMessage — fixes <bold> rendering as literal text
- Biome format pass: fix 23 lint/format errors across scripts, services, tests
- Planning: mark all UAT and verification gaps resolved for phases 07, 11, 16, 20, 21, 22, 24, 32, 34; close debug sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 15:36:16 +02:00
parent 16058d0f4d
commit 4ccbb2b070
40 changed files with 807 additions and 227 deletions

View File

@@ -16,6 +16,8 @@ export function CatalogSearchOverlay() {
const catalogSearchOpen = useUIStore((s) => s.catalogSearchOpen);
const catalogSearchMode = useUIStore((s) => s.catalogSearchMode);
const closeCatalogSearch = useUIStore((s) => s.closeCatalogSearch);
const openAddToCollection = useUIStore((s) => s.openAddToCollection);
const openAddToThread = useUIStore((s) => s.openAddToThread);
const [searchInput, setSearchInput] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
@@ -131,9 +133,13 @@ export function CatalogSearchOverlay() {
});
}
function handleAddStub(e: React.MouseEvent) {
function handleAdd(e: React.MouseEvent, itemId: number, itemName: string) {
e.stopPropagation();
// Stub: actual add-to-collection / add-to-thread wired in Phase 22
if (catalogSearchMode === "collection") {
openAddToCollection(itemId, itemName);
} else {
openAddToThread(itemId, itemName);
}
}
const contextText = (() => {
@@ -555,7 +561,13 @@ export function CatalogSearchOverlay() {
<GridCard
key={item.id}
item={item}
onAdd={handleAddStub}
onAdd={(e) =>
handleAdd(
e,
item.id,
`${item.brand} ${item.model}`,
)
}
onCardClick={() => handleCardClick(item.id)}
weight={weight}
price={price}
@@ -568,7 +580,13 @@ export function CatalogSearchOverlay() {
<ListRow
key={item.id}
item={item}
onAdd={handleAddStub}
onAdd={(e) =>
handleAdd(
e,
item.id,
`${item.brand} ${item.model}`,
)
}
onCardClick={() => handleCardClick(item.id)}
weight={weight}
price={price}

View File

@@ -1,4 +1,4 @@
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { useDeleteItem, useItems } from "../hooks/useItems";
import { useUIStore } from "../stores/uiStore";
@@ -35,7 +35,11 @@ export function ConfirmDialog() {
{t("confirm.deleteItem")}
</h3>
<p className="text-sm text-gray-600 mb-6">
{t("confirm.deleteItemMessage", { name: itemName })}
<Trans
i18nKey="confirm.deleteItemMessage"
values={{ name: itemName }}
components={{ bold: <strong /> }}
/>
</p>
<div className="flex justify-end gap-3">
<button

View File

@@ -8,7 +8,7 @@ import {
useRouter,
} from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { Toaster } from "sonner";
import "../app.css";
import { AddToCollectionModal } from "../components/AddToCollectionModal";
@@ -244,7 +244,11 @@ function CandidateDeleteDialog({
{t("confirm.deleteCandidate")}
</h3>
<p className="text-sm text-gray-600 mb-6">
{t("confirm.deleteCandidateMessage", { name: candidateName })}
<Trans
i18nKey="confirm.deleteCandidateMessage"
values={{ name: candidateName }}
components={{ bold: <strong /> }}
/>
</p>
<div className="flex justify-end gap-3">
<button
@@ -308,7 +312,11 @@ function ResolveDialog({
{t("confirm.pickWinner")}
</h3>
<p className="text-sm text-gray-600 mb-6">
{t("confirm.pickWinnerMessage", { name: candidateName })}
<Trans
i18nKey="confirm.pickWinnerMessage"
values={{ name: candidateName }}
components={{ bold: <strong /> }}
/>
</p>
<div className="flex justify-end gap-3">
<button

View File

@@ -21,21 +21,111 @@ export const DEV_CATEGORIES = [
// Seeded with onConflictDoNothing — safe to overlap with SEED_MANUFACTURERS.
export const DEV_MANUFACTURERS = [
{ name: "Rockgeist", slug: "rockgeist", website: "https://rockgeist.com", country: "US", tier: 1 },
{ name: "Oveja Negra", slug: "oveja-negra", website: "https://ovejanegrabikewear.com", country: "US", tier: 1 },
{ name: "Durston", slug: "durston", website: "https://durstondesigns.com", country: "US", tier: 1 },
{ name: "Enlightened Equipment", slug: "enlightened-equipment", website: "https://enlightenedequipment.com", country: "US", tier: 1 },
{ name: "BRS", slug: "brs", website: "https://brs-outdoor.com", country: "CN", tier: 1 },
{ name: "Soto", slug: "soto", website: "https://sotostoves.com", country: "JP", tier: 1 },
{ name: "Snow Peak", slug: "snow-peak", website: "https://snowpeak.com", country: "JP", tier: 1 },
{ name: "Lezyne", slug: "lezyne", website: "https://lezyne.com", country: "US", tier: 1 },
{ name: "Fenix", slug: "fenix", website: "https://fenixlighting.com", country: "CN", tier: 1 },
{ name: "Park Tool", slug: "park-tool", website: "https://parktool.com", country: "US", tier: 1 },
{ name: "Gorilla Tape", slug: "gorilla-tape", website: "https://gorillatough.com", country: "US", tier: 1 },
{ name: "Patagonia", slug: "patagonia", website: "https://patagonia.com", country: "US", tier: 1 },
{ name: "Frogg Toggs", slug: "frogg-toggs", website: "https://froggtoggs.com", country: "US", tier: 1 },
{ name: "Buff", slug: "buff", website: "https://buffwear.com", country: "ES", tier: 1 },
{ name: "Anker", slug: "anker", website: "https://anker.com", country: "CN", tier: 1 },
{
name: "Rockgeist",
slug: "rockgeist",
website: "https://rockgeist.com",
country: "US",
tier: 1,
},
{
name: "Oveja Negra",
slug: "oveja-negra",
website: "https://ovejanegrabikewear.com",
country: "US",
tier: 1,
},
{
name: "Durston",
slug: "durston",
website: "https://durstondesigns.com",
country: "US",
tier: 1,
},
{
name: "Enlightened Equipment",
slug: "enlightened-equipment",
website: "https://enlightenedequipment.com",
country: "US",
tier: 1,
},
{
name: "BRS",
slug: "brs",
website: "https://brs-outdoor.com",
country: "CN",
tier: 1,
},
{
name: "Soto",
slug: "soto",
website: "https://sotostoves.com",
country: "JP",
tier: 1,
},
{
name: "Snow Peak",
slug: "snow-peak",
website: "https://snowpeak.com",
country: "JP",
tier: 1,
},
{
name: "Lezyne",
slug: "lezyne",
website: "https://lezyne.com",
country: "US",
tier: 1,
},
{
name: "Fenix",
slug: "fenix",
website: "https://fenixlighting.com",
country: "CN",
tier: 1,
},
{
name: "Park Tool",
slug: "park-tool",
website: "https://parktool.com",
country: "US",
tier: 1,
},
{
name: "Gorilla Tape",
slug: "gorilla-tape",
website: "https://gorillatough.com",
country: "US",
tier: 1,
},
{
name: "Patagonia",
slug: "patagonia",
website: "https://patagonia.com",
country: "US",
tier: 1,
},
{
name: "Frogg Toggs",
slug: "frogg-toggs",
website: "https://froggtoggs.com",
country: "US",
tier: 1,
},
{
name: "Buff",
slug: "buff",
website: "https://buffwear.com",
country: "ES",
tier: 1,
},
{
name: "Anker",
slug: "anker",
website: "https://anker.com",
country: "CN",
tier: 1,
},
] as const;
// ── Global Items ───────────────────────────────────────────────────

View File

@@ -83,7 +83,10 @@ async function seedDevData(database: Db = db) {
// ── 1. Seed global items, tags, and dev-specific manufacturers ─
await seedGlobalItems(database);
for (const m of DEV_MANUFACTURERS) {
await database.insert(schema.manufacturers).values(m).onConflictDoNothing();
await database
.insert(schema.manufacturers)
.values(m)
.onConflictDoNothing();
}
console.log(" Global items, tags, and manufacturers seeded.");
@@ -145,7 +148,9 @@ async function seedDevData(database: Db = db) {
for (const item of DEV_GLOBAL_ITEMS) {
const mfId = mfBySlug.get(item.manufacturerSlug);
if (!mfId) {
console.warn(` Skipping "${item.model}" — unknown manufacturer slug: ${item.manufacturerSlug}`);
console.warn(
` Skipping "${item.model}" — unknown manufacturer slug: ${item.manufacturerSlug}`,
);
globalItemIds.push(0); // placeholder to keep index alignment
continue;
}

View File

@@ -5,58 +5,261 @@ import { globalItems, manufacturers, tags } from "./schema.ts";
type Db = typeof prodDb;
export const SEED_MANUFACTURERS = [
{ name: "Revelate Designs", slug: "revelate-designs", website: "https://revelatedesigns.com", country: "US", tier: 1 },
{ name: "Apidura", slug: "apidura", website: "https://apidura.com", country: "GB", tier: 1 },
{ name: "Ortlieb", slug: "ortlieb", website: "https://ortlieb.com", country: "DE", tier: 1 },
{ name: "Big Agnes", slug: "big-agnes", website: "https://bigagnes.com", country: "US", tier: 1 },
{ name: "Tarptent", slug: "tarptent", website: "https://tarptent.com", country: "US", tier: 1 },
{ name: "Zpacks", slug: "zpacks", website: "https://zpacks.com", country: "US", tier: 1 },
{ name: "Sea to Summit", slug: "sea-to-summit", website: "https://seatosummit.com", country: "AU", tier: 1 },
{ name: "Western Mountaineering", slug: "western-mountaineering", website: "https://westernmountaineering.com", country: "US", tier: 1 },
{ name: "MSR", slug: "msr", website: "https://msrgear.com", country: "US", tier: 1 },
{ name: "BioLite", slug: "biolite", website: "https://bioliteenergy.com", country: "US", tier: 1 },
{ name: "Petzl", slug: "petzl", website: "https://petzl.com", country: "FR", tier: 1 },
{ name: "Black Diamond", slug: "black-diamond", website: "https://blackdiamondequipment.com", country: "US", tier: 1 },
{ name: "Garmin", slug: "garmin", website: "https://garmin.com", country: "US", tier: 1 },
{ name: "Wahoo", slug: "wahoo", website: "https://wahoofitness.com", country: "US", tier: 1 },
{ name: "Sawyer", slug: "sawyer", website: "https://sawyerproducts.com", country: "US", tier: 1 },
{ name: "Canyon", slug: "canyon", website: "https://canyon.com", country: "DE", tier: 1 },
{ name: "Specialized", slug: "specialized", website: "https://specialized.com", country: "US", tier: 1 },
{ name: "Trek", slug: "trek", website: "https://trekbikes.com", country: "US", tier: 1 },
{ name: "Salsa Cycles", slug: "salsa-cycles", website: "https://salsacycles.com", country: "US", tier: 1 },
{ name: "Surly", slug: "surly", website: "https://surlybikes.com", country: "US", tier: 1 },
{
name: "Revelate Designs",
slug: "revelate-designs",
website: "https://revelatedesigns.com",
country: "US",
tier: 1,
},
{
name: "Apidura",
slug: "apidura",
website: "https://apidura.com",
country: "GB",
tier: 1,
},
{
name: "Ortlieb",
slug: "ortlieb",
website: "https://ortlieb.com",
country: "DE",
tier: 1,
},
{
name: "Big Agnes",
slug: "big-agnes",
website: "https://bigagnes.com",
country: "US",
tier: 1,
},
{
name: "Tarptent",
slug: "tarptent",
website: "https://tarptent.com",
country: "US",
tier: 1,
},
{
name: "Zpacks",
slug: "zpacks",
website: "https://zpacks.com",
country: "US",
tier: 1,
},
{
name: "Sea to Summit",
slug: "sea-to-summit",
website: "https://seatosummit.com",
country: "AU",
tier: 1,
},
{
name: "Western Mountaineering",
slug: "western-mountaineering",
website: "https://westernmountaineering.com",
country: "US",
tier: 1,
},
{
name: "MSR",
slug: "msr",
website: "https://msrgear.com",
country: "US",
tier: 1,
},
{
name: "BioLite",
slug: "biolite",
website: "https://bioliteenergy.com",
country: "US",
tier: 1,
},
{
name: "Petzl",
slug: "petzl",
website: "https://petzl.com",
country: "FR",
tier: 1,
},
{
name: "Black Diamond",
slug: "black-diamond",
website: "https://blackdiamondequipment.com",
country: "US",
tier: 1,
},
{
name: "Garmin",
slug: "garmin",
website: "https://garmin.com",
country: "US",
tier: 1,
},
{
name: "Wahoo",
slug: "wahoo",
website: "https://wahoofitness.com",
country: "US",
tier: 1,
},
{
name: "Sawyer",
slug: "sawyer",
website: "https://sawyerproducts.com",
country: "US",
tier: 1,
},
{
name: "Canyon",
slug: "canyon",
website: "https://canyon.com",
country: "DE",
tier: 1,
},
{
name: "Specialized",
slug: "specialized",
website: "https://specialized.com",
country: "US",
tier: 1,
},
{
name: "Trek",
slug: "trek",
website: "https://trekbikes.com",
country: "US",
tier: 1,
},
{
name: "Salsa Cycles",
slug: "salsa-cycles",
website: "https://salsacycles.com",
country: "US",
tier: 1,
},
{
name: "Surly",
slug: "surly",
website: "https://surlybikes.com",
country: "US",
tier: 1,
},
// Additional manufacturers referenced in seed data
{ name: "Nemo", slug: "nemo", website: "https://nemoequipment.com", country: "US", tier: 1 },
{ name: "Therm-a-Rest", slug: "therm-a-rest", website: "https://thermarest.com", country: "US", tier: 1 },
{ name: "Toaks", slug: "toaks", website: "https://toaksoutdoor.com", country: "CN", tier: 1 },
{ name: "Katadyn", slug: "katadyn", website: "https://katadyn.com", country: "CH", tier: 1 },
{ name: "HydraPak", slug: "hydrapak", website: "https://hydrapak.com", country: "US", tier: 1 },
{ name: "Nitecore", slug: "nitecore", website: "https://nitecore.com", country: "CN", tier: 1 },
{ name: "Outdoor Research", slug: "outdoor-research", website: "https://outdoorresearch.com", country: "US", tier: 1 },
{ name: "Exposure Lights", slug: "exposure-lights", website: "https://exposurelights.com", country: "GB", tier: 1 },
{
name: "Nemo",
slug: "nemo",
website: "https://nemoequipment.com",
country: "US",
tier: 1,
},
{
name: "Therm-a-Rest",
slug: "therm-a-rest",
website: "https://thermarest.com",
country: "US",
tier: 1,
},
{
name: "Toaks",
slug: "toaks",
website: "https://toaksoutdoor.com",
country: "CN",
tier: 1,
},
{
name: "Katadyn",
slug: "katadyn",
website: "https://katadyn.com",
country: "CH",
tier: 1,
},
{
name: "HydraPak",
slug: "hydrapak",
website: "https://hydrapak.com",
country: "US",
tier: 1,
},
{
name: "Nitecore",
slug: "nitecore",
website: "https://nitecore.com",
country: "CN",
tier: 1,
},
{
name: "Outdoor Research",
slug: "outdoor-research",
website: "https://outdoorresearch.com",
country: "US",
tier: 1,
},
{
name: "Exposure Lights",
slug: "exposure-lights",
website: "https://exposurelights.com",
country: "GB",
tier: 1,
},
];
const SEED_TAGS = [
"bikepacking", "cycling", "hiking", "backpacking", "camping", "climbing",
"mountaineering", "road-cycling", "gravel", "running", "trail-running",
"handlebar-bag", "framebag", "saddlebag", "top-tube-bag", "stem-bag",
"fork-bag", "feed-bag", "dry-bag", "stuff-sack", "bike-bag",
"tent", "bivy", "tarp", "hammock",
"sleeping-bag", "sleeping-pad", "quilt", "pillow",
"stove", "cookware", "mug", "utensils",
"water-filter", "water-bottle",
"headlamp", "bike-light", "lantern",
"gps", "bike-computer", "power-bank", "solar-panel",
"multi-tool", "pump", "repair-kit", "lock",
"rain-jacket", "base-layer", "gloves", "shoe",
"bikepacking",
"cycling",
"hiking",
"backpacking",
"camping",
"climbing",
"mountaineering",
"road-cycling",
"gravel",
"running",
"trail-running",
"handlebar-bag",
"framebag",
"saddlebag",
"top-tube-bag",
"stem-bag",
"fork-bag",
"feed-bag",
"dry-bag",
"stuff-sack",
"bike-bag",
"tent",
"bivy",
"tarp",
"hammock",
"sleeping-bag",
"sleeping-pad",
"quilt",
"pillow",
"stove",
"cookware",
"mug",
"utensils",
"water-filter",
"water-bottle",
"headlamp",
"bike-light",
"lantern",
"gps",
"bike-computer",
"power-bank",
"solar-panel",
"multi-tool",
"pump",
"repair-kit",
"lock",
"rain-jacket",
"base-layer",
"gloves",
"shoe",
];
export async function seedManufacturers(db: Db = prodDb) {
for (const m of SEED_MANUFACTURERS) {
await db
.insert(manufacturers)
.values(m)
.onConflictDoNothing();
await db.insert(manufacturers).values(m).onConflictDoNothing();
}
}

View File

@@ -18,9 +18,9 @@ import { communityPriceRoutes } from "./routes/community-prices.ts";
import { discoveryRoutes } from "./routes/discovery.ts";
import { exchangeRateRoutes } from "./routes/exchange-rates.ts";
import { globalItemRoutes } from "./routes/global-items.ts";
import { manufacturerRoutes } from "./routes/manufacturers.ts";
import { imageRoutes } from "./routes/images.ts";
import { itemRoutes } from "./routes/items.ts";
import { manufacturerRoutes } from "./routes/manufacturers.ts";
import { marketPriceRoutes } from "./routes/market-prices.ts";
import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts";
import { onboardingRoutes } from "./routes/onboarding.ts";

View File

@@ -22,10 +22,14 @@ function errorResult(message: string): ToolResult {
}
const catalogItemInputSchema = {
manufacturerSlug: z.string().describe("Manufacturer slug (e.g. 'revelate-designs', 'apidura')"),
manufacturerSlug: z
.string()
.describe("Manufacturer slug (e.g. 'revelate-designs', 'apidura')"),
model: z
.string()
.describe("Model name — combined with manufacturerSlug forms the unique identifier"),
.describe(
"Model name — combined with manufacturerSlug forms the unique identifier",
),
category: z
.string()
.optional()

View File

@@ -31,7 +31,10 @@ app.post("/", zValidator("json", createManufacturerSchema), async (c) => {
const manufacturer = await createManufacturer(db, data);
return c.json(manufacturer, 201);
} catch {
return c.json({ error: "Manufacturer with this name or slug already exists" }, 409);
return c.json(
{ error: "Manufacturer with this name or slug already exists" },
409,
);
}
});

View File

@@ -1,6 +1,11 @@
import { eq, sql } from "drizzle-orm";
import type { db as prodDb } from "../../db/index.ts";
import { categories, globalItems, items, manufacturers } from "../../db/schema.ts";
import {
categories,
globalItems,
items,
manufacturers,
} from "../../db/schema.ts";
import { getOrCreateUncategorized } from "./category.service.ts";
type Db = typeof prodDb;

View File

@@ -1,12 +1,21 @@
import type { SQL } from "drizzle-orm";
import { and, count, eq, ilike, or, sql } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { globalItems, globalItemTags, items, manufacturers, tags } from "../../db/schema.ts";
import {
globalItems,
globalItemTags,
items,
manufacturers,
tags,
} from "../../db/schema.ts";
type Db = typeof prodDb;
type TxDb = Parameters<Parameters<Db["transaction"]>[0]>[0];
async function resolveManufacturerId(db: Db | TxDb, slug: string): Promise<number> {
async function resolveManufacturerId(
db: Db | TxDb,
slug: string,
): Promise<number> {
const [m] = await (db as Db)
.select({ id: manufacturers.id })
.from(manufacturers)
@@ -26,7 +35,10 @@ export async function searchGlobalItems(
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
const pattern = `%${escaped}%`;
conditions.push(
or(ilike(manufacturers.name, pattern), ilike(globalItems.model, pattern))!,
or(
ilike(manufacturers.name, pattern),
ilike(globalItems.model, pattern),
)!,
);
}
@@ -221,7 +233,10 @@ export async function bulkUpsertGlobalItems(
const resultItems = [];
for (const data of itemsData) {
const manufacturerId = await resolveManufacturerId(tx as unknown as Db, data.manufacturerSlug);
const manufacturerId = await resolveManufacturerId(
tx as unknown as Db,
data.manufacturerSlug,
);
const [existing] = await tx
.select({ id: globalItems.id })

View File

@@ -1,6 +1,11 @@
import { and, eq, sql } from "drizzle-orm";
import type { db as prodDb } from "../../db/index.ts";
import { categories, globalItems, items, manufacturers } from "../../db/schema.ts";
import {
categories,
globalItems,
items,
manufacturers,
} from "../../db/schema.ts";
import type { CreateItem } from "../../shared/types.ts";
type Db = typeof prodDb;
@@ -122,7 +127,10 @@ export async function createItem(
const [gi] = await db
.select({ name: manufacturers.name, model: globalItems.model })
.from(globalItems)
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.innerJoin(
manufacturers,
eq(globalItems.manufacturerId, manufacturers.id),
)
.where(eq(globalItems.id, data.globalItemId));
if (gi) {
name = `${gi.name} ${gi.model}`;

View File

@@ -371,7 +371,10 @@ export async function resolveThread(
const [gi] = await tx
.select({ name: manufacturers.name, model: globalItems.model })
.from(globalItems)
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.innerJoin(
manufacturers,
eq(globalItems.manufacturerId, manufacturers.id),
)
.where(eq(globalItems.id, candidate.globalItemId));
const fallbackName = gi ? `${gi.name} ${gi.model}` : candidate.name;
insertValues = {