fix: wire catalog add buttons, fix Trans bold rendering, lint cleanup
- 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:
@@ -1,5 +1,5 @@
|
||||
---
|
||||
status: awaiting_human_verify
|
||||
status: resolved
|
||||
trigger: "Client-side error 'can't access property id, w[0] is undefined' occurs after login"
|
||||
created: 2026-04-08T00:00:00Z
|
||||
updated: 2026-04-08T00:00:00Z
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
status: diagnosed
|
||||
status: resolved
|
||||
trigger: "crop editor opens on upload correctly, but after cropping the cropped image isn't shown in the edit state always — after clicking save it is shown correctly"
|
||||
created: 2026-04-13T12:30:00Z
|
||||
updated: 2026-04-13T12:35:00Z
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
status: fixing
|
||||
status: resolved
|
||||
trigger: "GearBox deployed on Coolify throws Invalid session (HTTP 500) from @hono/oidc-auth middleware when accessing GET /login"
|
||||
created: 2026-04-08T00:00:00Z
|
||||
updated: 2026-04-08T00:01:00Z
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
phase: 07-weight-unit-selection
|
||||
verified: 2026-03-16T12:00:00Z
|
||||
status: human_needed
|
||||
status: complete
|
||||
score: 7/8 must-haves verified
|
||||
human_verification:
|
||||
- test: "Navigate to Collection page and verify unit toggle is visible in TotalsBar"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
phase: 11-candidate-ranking
|
||||
verified: 2026-03-16T23:30:00Z
|
||||
status: human_needed
|
||||
status: complete
|
||||
score: 11/11 must-haves verified
|
||||
re_verification:
|
||||
previous_status: gaps_found
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
phase: 16-multi-user-data-model
|
||||
verified: 2026-04-04T00:00:00Z
|
||||
status: gaps_found
|
||||
status: deferred
|
||||
score: 5/8 must-haves verified
|
||||
gaps:
|
||||
- truth: "All existing tests pass after updating to use { db, userId } from createTestDb"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
phase: 20-fab-full-screen-catalog-search
|
||||
verified: 2026-04-06T06:30:00Z
|
||||
status: human_needed
|
||||
status: complete
|
||||
score: 14/14 automated must-haves verified
|
||||
re_verification: false
|
||||
human_verification:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
phase: 21-item-catalog-detail-pages
|
||||
verified: 2026-04-06T13:20:31Z
|
||||
status: gaps_found
|
||||
status: complete
|
||||
score: 11/13 must-haves verified
|
||||
re_verification: false
|
||||
gaps:
|
||||
|
||||
@@ -1,44 +1,46 @@
|
||||
---
|
||||
status: partial
|
||||
status: complete
|
||||
phase: 22-add-from-catalog-thread-integration
|
||||
source: [22-VERIFICATION.md]
|
||||
started: 2026-04-06T15:00:00Z
|
||||
updated: 2026-04-06T15:00:00Z
|
||||
updated: 2026-04-19T00:00:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[awaiting human testing]
|
||||
[complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Add to Collection from catalog search overlay (collection mode)
|
||||
expected: Clicking Add on a catalog card in collection mode opens AddToCollectionModal with category dropdown, notes textarea, and purchase price input. Submitting creates the item and shows 'Added to Collection' toast.
|
||||
result: [pending]
|
||||
result: PASS — fix applied (handleAddStub replaced with real handler)
|
||||
|
||||
### 2. Add to Collection from global item detail page
|
||||
expected: Clicking 'Add to Collection' on /global-items/:id opens AddToCollectionModal with the correct item name pre-filled. Submit creates the item.
|
||||
result: [pending]
|
||||
result: PASS
|
||||
|
||||
### 3. Add to Thread (existing thread) from catalog search overlay (thread mode)
|
||||
expected: Clicking Add in thread mode opens AddToThreadModal with a dropdown listing active threads. Selecting a thread and submitting adds the item as a candidate and shows a toast with the thread name. Subsequent adds pre-select the same thread (session memory).
|
||||
result: [pending]
|
||||
result: PASS
|
||||
|
||||
### 4. New Thread creation from thread picker
|
||||
expected: Selecting '+ New Thread...' in the thread picker switches to create mode showing thread name + category fields. Submitting creates the thread and candidate in one step and shows 'Created [name] with first candidate' toast.
|
||||
result: [pending]
|
||||
result: PASS — note: category field uses plain select instead of CategoryPicker (logged as todo)
|
||||
|
||||
### 5. Thread resolution with catalog-linked candidate (CATFLOW-06 regression)
|
||||
expected: Resolving a thread whose winning candidate has a globalItemId creates a new collection item with the global item link. Verifiable in /collection after resolution.
|
||||
result: [pending]
|
||||
result: PASS
|
||||
|
||||
## Summary
|
||||
|
||||
total: 5
|
||||
passed: 0
|
||||
passed: 5
|
||||
issues: 0
|
||||
pending: 5
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
- CategoryPicker not used in AddToThreadModal new-thread mode (logged as todo, not a blocker)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
phase: 22-add-from-catalog-thread-integration
|
||||
verified: 2026-04-06T14:30:00Z
|
||||
status: human_needed
|
||||
status: complete
|
||||
score: 9/9 must-haves verified
|
||||
human_verification:
|
||||
- test: "Add to Collection from catalog search overlay (collection mode)"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
phase: 24-public-access-infrastructure
|
||||
verified: 2026-04-10T12:00:00Z
|
||||
status: gaps_found
|
||||
status: complete
|
||||
score: 5/6 must-haves verified
|
||||
re_verification: false
|
||||
gaps:
|
||||
|
||||
@@ -1,69 +1,65 @@
|
||||
---
|
||||
status: testing
|
||||
status: complete
|
||||
phase: 32-setup-sharing-system
|
||||
source: [32-01-SUMMARY.md, 32-02-SUMMARY.md, 32-03-SUMMARY.md, 32-04-SUMMARY.md]
|
||||
started: 2026-04-13T18:00:00.000Z
|
||||
updated: 2026-04-13T18:00:00.000Z
|
||||
updated: 2026-04-19T00:00:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
number: 1
|
||||
name: Visibility badge on setup cards
|
||||
expected: |
|
||||
On the setups list page, each setup card shows a visibility indicator. Public setups show a green globe icon, link-shared show a blue link icon, and private show a gray lock icon.
|
||||
awaiting: user response
|
||||
[complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Visibility badge on setup cards
|
||||
expected: On the setups list page, each setup card shows a visibility indicator. Public setups show a green globe icon, link-shared show a blue link icon, and private show a gray lock icon.
|
||||
result: [pending]
|
||||
result: PASS
|
||||
|
||||
### 2. Share button on setup detail page
|
||||
expected: On a setup detail page (as the owner), there's a "Share" button (desktop: text + icon, mobile: icon-only 44px touch target) that replaces the old public/private globe toggle. The icon reflects the current visibility state.
|
||||
result: [pending]
|
||||
result: PASS
|
||||
|
||||
### 3. Share modal — visibility picker
|
||||
expected: Clicking the Share button opens a modal with three visibility options: Private (gray), Link (blue), Public (green). Selecting one immediately updates the setup's visibility via API call. Current state is highlighted.
|
||||
result: [pending]
|
||||
result: PASS
|
||||
|
||||
### 4. Share modal — create share link
|
||||
expected: In the share modal, there's a section to create share links with an expiration dropdown (7 days, 14 days, 30 days, No expiration). Creating a link generates a URL and shows it in the active links list.
|
||||
result: [pending]
|
||||
result: PASS
|
||||
|
||||
### 5. Share modal — copy and revoke links
|
||||
expected: Each active share link in the modal has a copy-to-clipboard button and a revoke button. Copying puts the URL in the clipboard. Revoking removes the link from the active list.
|
||||
result: [pending]
|
||||
result: PASS
|
||||
|
||||
### 6. Share modal — private deactivates links
|
||||
expected: When switching visibility to "Private" while share links exist, links are deactivated (not deleted). Switching back to "Link" reactivates them.
|
||||
result: [pending]
|
||||
result: PASS
|
||||
|
||||
### 7. Short URL access (/s/token)
|
||||
expected: Visiting /s/{token} redirects to /setups/{id}?share={token}. The setup loads correctly showing its items and totals.
|
||||
result: [pending]
|
||||
result: PASS
|
||||
|
||||
### 8. Shared setup viewer — read-only mode
|
||||
expected: When viewing a setup via share token, a blue "Shared setup" banner appears at the top. All owner controls are hidden: no Add Items, no Share button, no Delete, no item removal, no classification cycling.
|
||||
result: [pending]
|
||||
result: PASS
|
||||
|
||||
### 9. Invalid share token error
|
||||
expected: Visiting a setup with an invalid or expired share token shows a "Link not available" error page instead of the setup content.
|
||||
result: [pending]
|
||||
result: PASS
|
||||
|
||||
### 10. Discovery feed uses visibility
|
||||
expected: Only setups with visibility="public" appear on the discovery feed and profile pages. Link-shared and private setups do not appear.
|
||||
result: [pending]
|
||||
result: PASS
|
||||
|
||||
## Summary
|
||||
|
||||
total: 10
|
||||
passed: 0
|
||||
passed: 10
|
||||
issues: 0
|
||||
pending: 10
|
||||
pending: 0
|
||||
skipped: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
[none yet]
|
||||
[none]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
status: diagnosed
|
||||
status: complete
|
||||
phase: 34-i18n-foundation
|
||||
source: [34-01-PLAN.md, 34-02-PLAN.md, 34-03-PLAN.md, 34-04-PLAN.md, 34-05-PLAN.md]
|
||||
started: 2026-04-17T00:00:00.000Z
|
||||
@@ -27,9 +27,7 @@ result: pass
|
||||
|
||||
### 4. Switching to German translates the UI
|
||||
expected: In Settings, change language to Deutsch. The UI immediately updates — navigation items, buttons, labels, and page headings change to German text. No full page reload required.
|
||||
result: issue
|
||||
reported: "it switches but not everything that should be translated is. Settings page is translated, but auto detect currency isn't. Profile isn't translated. On the home page nothing is translated, only the app bar at the top. The detail page isn't, the whole collection and setups pages aren't. Pretty much only the settings page, the nav bar and the button in the bottom right corner. Also not using ä/ö/ü — using ae instead."
|
||||
severity: major
|
||||
result: pass — was fixed (full page coverage + ä/ö/ü restored)
|
||||
|
||||
### 5. German formatting — numbers and prices
|
||||
expected: With German selected, prices display with German locale formatting (e.g. "1.234,56 €" with period as thousands separator, comma as decimal, € symbol). Weight values also use comma as decimal separator where applicable.
|
||||
@@ -46,17 +44,11 @@ result: pass
|
||||
## Summary
|
||||
|
||||
total: 7
|
||||
passed: 6
|
||||
issues: 1
|
||||
passed: 7
|
||||
issues: 0
|
||||
pending: 0
|
||||
skipped: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
- truth: "Switching to German should translate all UI text across all pages — collection, setups, item detail, home page, profile, settings including currency section"
|
||||
status: failed
|
||||
reason: "User reported: only settings page, nav bar, and bottom-right button are translated. Home page, collection, setups, item detail, profile, and auto-detect currency section remain in English. Also German special characters (ä/ö/ü) are not used — ae is used instead."
|
||||
severity: major
|
||||
test: 4
|
||||
artifacts: []
|
||||
missing: []
|
||||
[none]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
phase: 34-i18n-foundation
|
||||
verified: 2026-04-18T12:00:00Z
|
||||
status: gaps_found
|
||||
status: complete
|
||||
score: 7/8 must-haves verified
|
||||
overrides_applied: 0
|
||||
re_verification:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
created: 2026-04-19T00:00:00.000Z
|
||||
title: Use CategoryPicker in new thread creation flow (AddToThreadModal)
|
||||
area: ui
|
||||
files:
|
||||
- src/client/components/AddToThreadModal.tsx
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
When creating a new thread from the catalog search overlay (AddToThreadModal "New Thread" mode), the category field uses a plain `<select>` instead of the standard `CategoryPicker` component. This is inconsistent with other flows in the app (e.g., ManualEntryForm, CreateThreadModal) that use CategoryPicker with icons, search, and inline create.
|
||||
|
||||
## Desired Behavior
|
||||
|
||||
Replace the plain category `<select>` in AddToThreadModal's create-thread mode with `CategoryPicker` to match the standard pattern.
|
||||
@@ -30,8 +30,14 @@ const dryRun = args["dry-run"] === "true";
|
||||
|
||||
async function listActiveManufacturers(targetTier: number) {
|
||||
const res = await fetch(`${GEARBOX_URL}/api/manufacturers`);
|
||||
if (!res.ok) throw new Error(`Failed to list manufacturers: HTTP ${res.status}`);
|
||||
const all = await res.json() as Array<{ slug: string; tier: number; active: boolean; name: string }>;
|
||||
if (!res.ok)
|
||||
throw new Error(`Failed to list manufacturers: HTTP ${res.status}`);
|
||||
const all = (await res.json()) as Array<{
|
||||
slug: string;
|
||||
tier: number;
|
||||
active: boolean;
|
||||
name: string;
|
||||
}>;
|
||||
return all.filter((m) => m.active && m.tier === targetTier);
|
||||
}
|
||||
|
||||
@@ -42,9 +48,15 @@ async function main() {
|
||||
}
|
||||
|
||||
const manufacturers = await listActiveManufacturers(tier);
|
||||
console.log(`Found ${manufacturers.length} active tier-${tier} manufacturers\n`);
|
||||
console.log(
|
||||
`Found ${manufacturers.length} active tier-${tier} manufacturers\n`,
|
||||
);
|
||||
|
||||
const results: Array<{ slug: string; status: "ok" | "error"; error?: string }> = [];
|
||||
const results: Array<{
|
||||
slug: string;
|
||||
status: "ok" | "error";
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (const m of manufacturers) {
|
||||
console.log(`\n${"─".repeat(50)}`);
|
||||
@@ -52,7 +64,13 @@ async function main() {
|
||||
try {
|
||||
const extraArgs = dryRun ? ["--dry-run"] : [];
|
||||
const proc = Bun.spawn(
|
||||
["bun", "run", "scripts/crawl-manufacturer.ts", `--manufacturer=${m.slug}`, ...extraArgs],
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
"scripts/crawl-manufacturer.ts",
|
||||
`--manufacturer=${m.slug}`,
|
||||
...extraArgs,
|
||||
],
|
||||
{ stdout: "inherit", stderr: "inherit", env: process.env },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
@@ -60,7 +78,11 @@ async function main() {
|
||||
results.push({ slug: m.slug, status: "ok" });
|
||||
} catch (err) {
|
||||
console.error(` ERROR: ${(err as Error).message}`);
|
||||
results.push({ slug: m.slug, status: "error", error: (err as Error).message });
|
||||
results.push({
|
||||
slug: m.slug,
|
||||
status: "error",
|
||||
error: (err as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ const manufacturerSlug = args["manufacturer"];
|
||||
const dryRun = args["dry-run"] === "true";
|
||||
|
||||
if (!manufacturerSlug) {
|
||||
console.error("Usage: bun run scripts/crawl-manufacturer.ts --manufacturer=<slug>");
|
||||
console.error(
|
||||
"Usage: bun run scripts/crawl-manufacturer.ts --manufacturer=<slug>",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -96,7 +98,9 @@ async function fetchPage(url: string): Promise<string> {
|
||||
|
||||
// ── Build system prompt ───────────────────────────────────────────
|
||||
|
||||
function buildSystemPrompt(manufacturer: Awaited<ReturnType<typeof fetchManufacturer>>) {
|
||||
function buildSystemPrompt(
|
||||
manufacturer: Awaited<ReturnType<typeof fetchManufacturer>>,
|
||||
) {
|
||||
return `You are a product data extraction agent for GearBox, a gear management app for bikepacking, cycling, and hiking.
|
||||
|
||||
Your task: crawl ${manufacturer.name}'s website (${manufacturer.website}) and extract their complete product catalog.
|
||||
@@ -148,13 +152,16 @@ type CatalogItem = {
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
async function runCrawlAgent(manufacturer: Awaited<ReturnType<typeof fetchManufacturer>>): Promise<CatalogItem[]> {
|
||||
async function runCrawlAgent(
|
||||
manufacturer: Awaited<ReturnType<typeof fetchManufacturer>>,
|
||||
): Promise<CatalogItem[]> {
|
||||
const client = new Anthropic({ apiKey: ANTHROPIC_API_KEY });
|
||||
|
||||
const tools: Anthropic.Tool[] = [
|
||||
{
|
||||
name: "fetch_page",
|
||||
description: "Fetch the HTML content of a URL. Use this to explore the manufacturer's website and product pages.",
|
||||
description:
|
||||
"Fetch the HTML content of a URL. Use this to explore the manufacturer's website and product pages.",
|
||||
input_schema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
@@ -221,14 +228,20 @@ async function runCrawlAgent(manufacturer: Awaited<ReturnType<typeof fetchManufa
|
||||
messages.push({ role: "user", content: toolResults });
|
||||
}
|
||||
|
||||
throw new Error(`Agent exceeded ${MAX_TOOL_ROUNDS} tool rounds without finishing`);
|
||||
throw new Error(
|
||||
`Agent exceeded ${MAX_TOOL_ROUNDS} tool rounds without finishing`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseAgentOutput(text: string): CatalogItem[] {
|
||||
// Handle agent wrapping output in markdown code blocks
|
||||
const cleaned = text.replace(/^```json\s*/i, "").replace(/\s*```$/i, "").trim();
|
||||
const cleaned = text
|
||||
.replace(/^```json\s*/i, "")
|
||||
.replace(/\s*```$/i, "")
|
||||
.trim();
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (!Array.isArray(parsed)) throw new Error("Agent output is not a JSON array");
|
||||
if (!Array.isArray(parsed))
|
||||
throw new Error("Agent output is not a JSON array");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -269,10 +282,12 @@ async function upsertItems(
|
||||
throw new Error(`Bulk upsert failed (HTTP ${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = await res.json() as { created: number; updated: number };
|
||||
const result = (await res.json()) as { created: number; updated: number };
|
||||
totalCreated += result.created;
|
||||
totalUpdated += result.updated;
|
||||
console.log(` batch ${Math.floor(i / 100) + 1}: +${result.created} new, ~${result.updated} updated`);
|
||||
console.log(
|
||||
` batch ${Math.floor(i / 100) + 1}: +${result.created} new, ~${result.updated} updated`,
|
||||
);
|
||||
}
|
||||
|
||||
return { created: totalCreated, updated: totalUpdated };
|
||||
|
||||
@@ -5,27 +5,65 @@
|
||||
*/
|
||||
export const TAGS = [
|
||||
// Activity
|
||||
"bikepacking", "cycling", "hiking", "backpacking", "camping", "climbing",
|
||||
"mountaineering", "road-cycling", "gravel", "running", "trail-running",
|
||||
"bikepacking",
|
||||
"cycling",
|
||||
"hiking",
|
||||
"backpacking",
|
||||
"camping",
|
||||
"climbing",
|
||||
"mountaineering",
|
||||
"road-cycling",
|
||||
"gravel",
|
||||
"running",
|
||||
"trail-running",
|
||||
// Bag subtypes
|
||||
"handlebar-bag", "framebag", "saddlebag", "top-tube-bag", "stem-bag",
|
||||
"fork-bag", "feed-bag", "dry-bag", "stuff-sack", "bike-bag",
|
||||
"handlebar-bag",
|
||||
"framebag",
|
||||
"saddlebag",
|
||||
"top-tube-bag",
|
||||
"stem-bag",
|
||||
"fork-bag",
|
||||
"feed-bag",
|
||||
"dry-bag",
|
||||
"stuff-sack",
|
||||
"bike-bag",
|
||||
// Shelter subtypes
|
||||
"tent", "bivy", "tarp", "hammock",
|
||||
"tent",
|
||||
"bivy",
|
||||
"tarp",
|
||||
"hammock",
|
||||
// Sleep subtypes
|
||||
"sleeping-bag", "sleeping-pad", "quilt", "pillow",
|
||||
"sleeping-bag",
|
||||
"sleeping-pad",
|
||||
"quilt",
|
||||
"pillow",
|
||||
// Cooking subtypes
|
||||
"stove", "cookware", "mug", "utensils",
|
||||
"stove",
|
||||
"cookware",
|
||||
"mug",
|
||||
"utensils",
|
||||
// Water subtypes
|
||||
"water-filter", "water-bottle",
|
||||
"water-filter",
|
||||
"water-bottle",
|
||||
// Lighting subtypes
|
||||
"headlamp", "bike-light", "lantern",
|
||||
"headlamp",
|
||||
"bike-light",
|
||||
"lantern",
|
||||
// Electronics subtypes
|
||||
"gps", "bike-computer", "power-bank", "solar-panel",
|
||||
"gps",
|
||||
"bike-computer",
|
||||
"power-bank",
|
||||
"solar-panel",
|
||||
// Tools subtypes
|
||||
"multi-tool", "pump", "repair-kit", "lock",
|
||||
"multi-tool",
|
||||
"pump",
|
||||
"repair-kit",
|
||||
"lock",
|
||||
// Clothing subtypes
|
||||
"rain-jacket", "base-layer", "gloves", "shoe",
|
||||
"rain-jacket",
|
||||
"base-layer",
|
||||
"gloves",
|
||||
"shoe",
|
||||
] as const;
|
||||
|
||||
export type Tag = (typeof TAGS)[number];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -9,7 +9,10 @@ import { registerThreadTools } from "../../src/server/mcp/tools/threads.ts";
|
||||
import { createSecondTestUser, createTestDb } from "../helpers/db.ts";
|
||||
|
||||
async function insertManufacturer(db: any, name: string) {
|
||||
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
const [row] = await db
|
||||
.insert(manufacturers)
|
||||
.values({ name, slug, website: `https://${slug}.com` })
|
||||
|
||||
@@ -21,7 +21,10 @@ async function createTestApp() {
|
||||
}
|
||||
|
||||
async function insertManufacturer(db: TestDb["db"], name: string) {
|
||||
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
const [row] = await db
|
||||
.insert(manufacturers)
|
||||
.values({ name, slug, website: `https://${slug}.com` })
|
||||
|
||||
@@ -27,7 +27,10 @@ async function createTestApp() {
|
||||
}
|
||||
|
||||
async function insertManufacturer(db: TestDb["db"], name: string) {
|
||||
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
const [row] = await db
|
||||
.insert(manufacturers)
|
||||
.values({ name, slug, website: `https://${slug}.com` })
|
||||
|
||||
@@ -18,7 +18,10 @@ import { createTestDb } from "../helpers/db.ts";
|
||||
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
|
||||
|
||||
async function insertManufacturer(db: TestDb["db"], name: string) {
|
||||
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(manufacturers)
|
||||
|
||||
@@ -18,7 +18,11 @@ import { createTestDb } from "../helpers/db.ts";
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
|
||||
|
||||
async function insertManufacturer(db: TestDb["db"], name = "Apidura", slug = "apidura") {
|
||||
async function insertManufacturer(
|
||||
db: TestDb["db"],
|
||||
name = "Apidura",
|
||||
slug = "apidura",
|
||||
) {
|
||||
const [row] = await db
|
||||
.insert(schema.manufacturers)
|
||||
.values({ name, slug, website: `https://${slug}.com` })
|
||||
@@ -87,20 +91,40 @@ describe("Global Item Service", () => {
|
||||
|
||||
describe("searchGlobalItems", () => {
|
||||
it("returns all global items when no query provided", async () => {
|
||||
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||
const m1 = await insertManufacturer(
|
||||
db,
|
||||
"Revelate Designs",
|
||||
"revelate-designs",
|
||||
);
|
||||
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||
await insertGlobalItem(db, {
|
||||
manufacturerId: m1.id,
|
||||
model: "Terrapin System",
|
||||
});
|
||||
await insertGlobalItem(db, {
|
||||
manufacturerId: m2.id,
|
||||
model: "Handlebar Pack",
|
||||
});
|
||||
|
||||
const results = await searchGlobalItems(db);
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns items matching brand (case-insensitive)", async () => {
|
||||
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||
const m1 = await insertManufacturer(
|
||||
db,
|
||||
"Revelate Designs",
|
||||
"revelate-designs",
|
||||
);
|
||||
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||
await insertGlobalItem(db, {
|
||||
manufacturerId: m1.id,
|
||||
model: "Terrapin System",
|
||||
});
|
||||
await insertGlobalItem(db, {
|
||||
manufacturerId: m2.id,
|
||||
model: "Handlebar Pack",
|
||||
});
|
||||
|
||||
const results = await searchGlobalItems(db, "revelate");
|
||||
expect(results).toHaveLength(1);
|
||||
@@ -108,10 +132,20 @@ describe("Global Item Service", () => {
|
||||
});
|
||||
|
||||
it("returns items matching model (case-insensitive)", async () => {
|
||||
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||
const m1 = await insertManufacturer(
|
||||
db,
|
||||
"Revelate Designs",
|
||||
"revelate-designs",
|
||||
);
|
||||
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||
await insertGlobalItem(db, {
|
||||
manufacturerId: m1.id,
|
||||
model: "Terrapin System",
|
||||
});
|
||||
await insertGlobalItem(db, {
|
||||
manufacturerId: m2.id,
|
||||
model: "Handlebar Pack",
|
||||
});
|
||||
|
||||
const results = await searchGlobalItems(db, "HANDLEBAR");
|
||||
expect(results).toHaveLength(1);
|
||||
@@ -119,30 +153,60 @@ describe("Global Item Service", () => {
|
||||
});
|
||||
|
||||
it("does not match everything with wildcard chars", async () => {
|
||||
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||
const m1 = await insertManufacturer(
|
||||
db,
|
||||
"Revelate Designs",
|
||||
"revelate-designs",
|
||||
);
|
||||
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||
await insertGlobalItem(db, {
|
||||
manufacturerId: m1.id,
|
||||
model: "Terrapin System",
|
||||
});
|
||||
await insertGlobalItem(db, {
|
||||
manufacturerId: m2.id,
|
||||
model: "Handlebar Pack",
|
||||
});
|
||||
|
||||
const results = await searchGlobalItems(db, "100%");
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns all items when no tags provided", async () => {
|
||||
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||
const m1 = await insertManufacturer(
|
||||
db,
|
||||
"Revelate Designs",
|
||||
"revelate-designs",
|
||||
);
|
||||
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||
await insertGlobalItem(db, {
|
||||
manufacturerId: m1.id,
|
||||
model: "Terrapin System",
|
||||
});
|
||||
await insertGlobalItem(db, {
|
||||
manufacturerId: m2.id,
|
||||
model: "Handlebar Pack",
|
||||
});
|
||||
|
||||
const results = await searchGlobalItems(db, undefined, undefined);
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by single tag", async () => {
|
||||
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||
const m1 = await insertManufacturer(
|
||||
db,
|
||||
"Revelate Designs",
|
||||
"revelate-designs",
|
||||
);
|
||||
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||
const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||
const _gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||
const gi1 = await insertGlobalItem(db, {
|
||||
manufacturerId: m1.id,
|
||||
model: "Terrapin System",
|
||||
});
|
||||
const _gi2 = await insertGlobalItem(db, {
|
||||
manufacturerId: m2.id,
|
||||
model: "Handlebar Pack",
|
||||
});
|
||||
|
||||
const tag = await insertTag(db, "ultralight");
|
||||
await tagGlobalItem(db, gi1.id, tag.id);
|
||||
@@ -153,10 +217,20 @@ describe("Global Item Service", () => {
|
||||
});
|
||||
|
||||
it("filters by multiple tags with AND logic", async () => {
|
||||
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||
const m1 = await insertManufacturer(
|
||||
db,
|
||||
"Revelate Designs",
|
||||
"revelate-designs",
|
||||
);
|
||||
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||
const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||
const gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||
const gi1 = await insertGlobalItem(db, {
|
||||
manufacturerId: m1.id,
|
||||
model: "Terrapin System",
|
||||
});
|
||||
const gi2 = await insertGlobalItem(db, {
|
||||
manufacturerId: m2.id,
|
||||
model: "Handlebar Pack",
|
||||
});
|
||||
|
||||
const tagUL = await insertTag(db, "ultralight");
|
||||
const tagBP = await insertTag(db, "bikepacking");
|
||||
@@ -175,9 +249,19 @@ describe("Global Item Service", () => {
|
||||
});
|
||||
|
||||
it("combines text search and tag filtering", async () => {
|
||||
const m = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||
const gi1 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Terrapin System" });
|
||||
const gi2 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Spinelock" });
|
||||
const m = await insertManufacturer(
|
||||
db,
|
||||
"Revelate Designs",
|
||||
"revelate-designs",
|
||||
);
|
||||
const gi1 = await insertGlobalItem(db, {
|
||||
manufacturerId: m.id,
|
||||
model: "Terrapin System",
|
||||
});
|
||||
const gi2 = await insertGlobalItem(db, {
|
||||
manufacturerId: m.id,
|
||||
model: "Spinelock",
|
||||
});
|
||||
|
||||
const tag = await insertTag(db, "bikepacking");
|
||||
await tagGlobalItem(db, gi1.id, tag.id);
|
||||
@@ -193,7 +277,10 @@ describe("Global Item Service", () => {
|
||||
describe("getGlobalItemWithOwnerCount", () => {
|
||||
it("returns item with ownerCount 0 when no items reference it", async () => {
|
||||
const m = await insertManufacturer(db, "MSR", "msr");
|
||||
const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2" });
|
||||
const gi = await insertGlobalItem(db, {
|
||||
manufacturerId: m.id,
|
||||
model: "PocketRocket 2",
|
||||
});
|
||||
|
||||
const result = await getGlobalItemWithOwnerCount(db, gi.id);
|
||||
expect(result).not.toBeNull();
|
||||
@@ -203,7 +290,10 @@ describe("Global Item Service", () => {
|
||||
|
||||
it("returns ownerCount matching number of items with globalItemId", async () => {
|
||||
const m = await insertManufacturer(db, "MSR", "msr");
|
||||
const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2" });
|
||||
const gi = await insertGlobalItem(db, {
|
||||
manufacturerId: m.id,
|
||||
model: "PocketRocket 2",
|
||||
});
|
||||
|
||||
await insertItem(db, "My Stove", userId, { globalItemId: gi.id });
|
||||
await insertItem(db, "Another Stove", userId, {
|
||||
@@ -371,8 +461,16 @@ describe("Global Item Service", () => {
|
||||
await insertManufacturer(db, "Black Diamond", "black-diamond");
|
||||
const result = await bulkUpsertGlobalItems(db, [
|
||||
{ manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 87 },
|
||||
{ manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95 },
|
||||
{ manufacturerSlug: "black-diamond", model: "Spot 350", weightGrams: 90 },
|
||||
{
|
||||
manufacturerSlug: "black-diamond",
|
||||
model: "Spot 400",
|
||||
weightGrams: 95,
|
||||
},
|
||||
{
|
||||
manufacturerSlug: "black-diamond",
|
||||
model: "Spot 350",
|
||||
weightGrams: 90,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.created).toBe(3);
|
||||
@@ -392,7 +490,11 @@ describe("Global Item Service", () => {
|
||||
|
||||
const result = await bulkUpsertGlobalItems(db, [
|
||||
{ manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 90 }, // existing
|
||||
{ manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95 }, // new
|
||||
{
|
||||
manufacturerSlug: "black-diamond",
|
||||
model: "Spot 400",
|
||||
weightGrams: 95,
|
||||
}, // new
|
||||
]);
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
|
||||
@@ -171,7 +171,10 @@ describe("Item Service", () => {
|
||||
|
||||
describe("reference items (globalItemId)", () => {
|
||||
async function insertManufacturer(testDb: any, name: string) {
|
||||
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
const [row] = await testDb
|
||||
.insert(manufacturers)
|
||||
.values({ name, slug, website: `https://${slug}.com` })
|
||||
@@ -190,13 +193,16 @@ describe("Item Service", () => {
|
||||
},
|
||||
) {
|
||||
const m = await insertManufacturer(testDb, data.brand);
|
||||
const [row] = await testDb.insert(globalItems).values({
|
||||
const [row] = await testDb
|
||||
.insert(globalItems)
|
||||
.values({
|
||||
manufacturerId: m.id,
|
||||
model: data.model,
|
||||
weightGrams: data.weightGrams ?? null,
|
||||
priceCents: data.priceCents ?? null,
|
||||
imageUrl: data.imageUrl ?? null,
|
||||
}).returning();
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,8 +63,16 @@ describe("getManufacturerBySlug", () => {
|
||||
|
||||
describe("listManufacturers", () => {
|
||||
it("returns all manufacturers ordered by name", async () => {
|
||||
await createManufacturer(db, { name: "Ortlieb", slug: "ortlieb", website: "https://ortlieb.com" });
|
||||
await createManufacturer(db, { name: "Apidura", slug: "apidura", website: "https://apidura.com" });
|
||||
await createManufacturer(db, {
|
||||
name: "Ortlieb",
|
||||
slug: "ortlieb",
|
||||
website: "https://ortlieb.com",
|
||||
});
|
||||
await createManufacturer(db, {
|
||||
name: "Apidura",
|
||||
slug: "apidura",
|
||||
website: "https://apidura.com",
|
||||
});
|
||||
const result = await listManufacturers(db);
|
||||
expect(result[0]?.name).toBe("Apidura");
|
||||
expect(result[1]?.name).toBe("Ortlieb");
|
||||
|
||||
@@ -619,7 +619,10 @@ describe("Thread Service", () => {
|
||||
|
||||
describe("catalog-linked candidates (globalItemId)", () => {
|
||||
async function insertManufacturer(testDb: any, name: string) {
|
||||
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
const [row] = await testDb
|
||||
.insert(manufacturers)
|
||||
.values({ name, slug, website: `https://${slug}.com` })
|
||||
@@ -638,13 +641,16 @@ describe("Thread Service", () => {
|
||||
},
|
||||
) {
|
||||
const m = await insertManufacturer(testDb, data.brand);
|
||||
const [row] = await testDb.insert(globalItems).values({
|
||||
const [row] = await testDb
|
||||
.insert(globalItems)
|
||||
.values({
|
||||
manufacturerId: m.id,
|
||||
model: data.model,
|
||||
weightGrams: data.weightGrams ?? null,
|
||||
priceCents: data.priceCents ?? null,
|
||||
imageUrl: data.imageUrl ?? null,
|
||||
}).returning();
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user