style(i18n): fix lint — formatting and import ordering across 21 files
Biome auto-fix for formatting (line length, ternary wrapping) and import organization in files touched by phase 34 i18n work. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
647
docs/superpowers/plans/2026-04-18-catalog-ingestion-script.md
Normal file
647
docs/superpowers/plans/2026-04-18-catalog-ingestion-script.md
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
# Catalog Ingestion Script Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a Bun script that takes a manufacturer slug, launches a Claude Haiku agent with web tools, crawls the manufacturer's site, and bulk-upserts the extracted products into the GearBox catalog via the existing API.
|
||||||
|
|
||||||
|
**Architecture:** `scripts/crawl-manufacturer.ts` is the entry point — it fetches the manufacturer record from the GearBox API, builds a structured prompt with the target schema and taxonomy, runs a Claude Haiku agent in an agentic tool-use loop (giving it a `fetch_page` tool backed by Bun's fetch), receives a JSON array of products, and posts them to `POST /api/global-items/bulk`. A `scripts/crawl-all.ts` batch runner iterates all active tier-1 manufacturers. Taxonomy (categories + tags) is defined in code and injected into the agent prompt.
|
||||||
|
|
||||||
|
**Tech Stack:** Bun, `@anthropic-ai/sdk`, Anthropic Claude (Haiku model for cost), GearBox API (local or deployed).
|
||||||
|
|
||||||
|
**Prerequisite:** Plan A (catalog-schema-migration) must be complete — the API must accept `manufacturerSlug` in bulk upserts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| Action | File |
|
||||||
|
|--------|------|
|
||||||
|
| Create | `scripts/taxonomy/categories.ts` — canonical category values |
|
||||||
|
| Create | `scripts/taxonomy/tags.ts` — canonical tag list |
|
||||||
|
| Create | `scripts/crawl-manufacturer.ts` — core agent runner |
|
||||||
|
| Create | `scripts/crawl-all.ts` — batch runner by tier |
|
||||||
|
| Modify | `package.json` — add `db:crawl` and `db:crawl-all` script entries |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Taxonomy files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/taxonomy/categories.ts`
|
||||||
|
- Create: `scripts/taxonomy/tags.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `scripts/taxonomy/categories.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Canonical category values for globalItems.category.
|
||||||
|
* These are the only valid values the ingestion agent should use.
|
||||||
|
*/
|
||||||
|
export const CATEGORIES = [
|
||||||
|
"bags", // bikepacking bags, dry bags, stuff sacks
|
||||||
|
"shelters", // tents, bivys, tarps, hammocks
|
||||||
|
"sleep", // sleeping bags, quilts, pads, pillows
|
||||||
|
"cooking", // stoves, cookware, mugs, utensils
|
||||||
|
"lighting", // headlamps, bike lights, lanterns
|
||||||
|
"water", // filters, bottles, bladders
|
||||||
|
"electronics", // power banks, solar panels, GPS, bike computers
|
||||||
|
"tools", // multi-tools, pumps, repair kits, locks
|
||||||
|
"clothing", // jackets, base layers, gloves, shoes
|
||||||
|
"navigation", // GPS devices, maps, compasses
|
||||||
|
"bikes", // complete bikes
|
||||||
|
"components", // drivetrain, brakes, wheels, handlebars, saddles, stems
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type Category = (typeof CATEGORIES)[number];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `scripts/taxonomy/tags.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Canonical tags for globalItems.
|
||||||
|
* Mirrors the seed tags in src/db/seed-global-items.ts.
|
||||||
|
* The agent should only use tags from this list.
|
||||||
|
*/
|
||||||
|
export const TAGS = [
|
||||||
|
// Activity
|
||||||
|
"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",
|
||||||
|
// Shelter subtypes
|
||||||
|
"tent", "bivy", "tarp", "hammock",
|
||||||
|
// Sleep subtypes
|
||||||
|
"sleeping-bag", "sleeping-pad", "quilt", "pillow",
|
||||||
|
// Cooking subtypes
|
||||||
|
"stove", "cookware", "mug", "utensils",
|
||||||
|
// Water subtypes
|
||||||
|
"water-filter", "water-bottle",
|
||||||
|
// Lighting subtypes
|
||||||
|
"headlamp", "bike-light", "lantern",
|
||||||
|
// Electronics subtypes
|
||||||
|
"gps", "bike-computer", "power-bank", "solar-panel",
|
||||||
|
// Tools subtypes
|
||||||
|
"multi-tool", "pump", "repair-kit", "lock",
|
||||||
|
// Clothing subtypes
|
||||||
|
"rain-jacket", "base-layer", "gloves", "shoe",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type Tag = (typeof TAGS)[number];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/taxonomy/
|
||||||
|
git commit -m "feat: canonical taxonomy — categories and tags for ingestion"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Core crawl script
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/crawl-manufacturer.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify `@anthropic-ai/sdk` is available**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun pm ls | grep anthropic
|
||||||
|
```
|
||||||
|
|
||||||
|
If not listed:
|
||||||
|
```bash
|
||||||
|
bun add @anthropic-ai/sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `scripts/crawl-manufacturer.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Crawl a manufacturer's website and upsert their products into the GearBox catalog.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run scripts/crawl-manufacturer.ts --manufacturer=apidura
|
||||||
|
* bun run scripts/crawl-manufacturer.ts --manufacturer=canyon --dry-run
|
||||||
|
*
|
||||||
|
* Env vars required:
|
||||||
|
* ANTHROPIC_API_KEY — Anthropic API key
|
||||||
|
* GEARBOX_URL — Base URL of the GearBox instance (default: http://localhost:3000)
|
||||||
|
* GEARBOX_API_KEY — GearBox API key with write access
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Anthropic from "@anthropic-ai/sdk";
|
||||||
|
import { CATEGORIES } from "./taxonomy/categories.ts";
|
||||||
|
import { TAGS } from "./taxonomy/tags.ts";
|
||||||
|
|
||||||
|
const GEARBOX_URL = process.env.GEARBOX_URL ?? "http://localhost:3000";
|
||||||
|
const GEARBOX_API_KEY = process.env.GEARBOX_API_KEY ?? "";
|
||||||
|
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? "";
|
||||||
|
const MODEL = "claude-haiku-4-5-20251001";
|
||||||
|
const MAX_TOOL_ROUNDS = 30; // safety limit
|
||||||
|
|
||||||
|
// ── Parse CLI args ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const args = Object.fromEntries(
|
||||||
|
process.argv
|
||||||
|
.slice(2)
|
||||||
|
.filter((a) => a.startsWith("--"))
|
||||||
|
.map((a) => {
|
||||||
|
const [k, v] = a.slice(2).split("=");
|
||||||
|
return [k, v ?? "true"];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const manufacturerSlug = args["manufacturer"];
|
||||||
|
const dryRun = args["dry-run"] === "true";
|
||||||
|
|
||||||
|
if (!manufacturerSlug) {
|
||||||
|
console.error("Usage: bun run scripts/crawl-manufacturer.ts --manufacturer=<slug>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!GEARBOX_API_KEY) {
|
||||||
|
console.error("GEARBOX_API_KEY env var is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ANTHROPIC_API_KEY) {
|
||||||
|
console.error("ANTHROPIC_API_KEY env var is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch manufacturer from GearBox ──────────────────────────────
|
||||||
|
|
||||||
|
async function fetchManufacturer(slug: string) {
|
||||||
|
const res = await fetch(`${GEARBOX_URL}/api/manufacturers/${slug}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Manufacturer not found: ${slug} (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
website: string;
|
||||||
|
tier: number;
|
||||||
|
country: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool: fetch a web page ────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchPage(url: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; GearBox-Catalog-Bot/1.0)",
|
||||||
|
Accept: "text/html,application/xhtml+xml",
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return `HTTP ${res.status} for ${url}`;
|
||||||
|
const html = await res.text();
|
||||||
|
// Strip scripts, styles, and excessive whitespace for token efficiency
|
||||||
|
return html
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||||
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, "")
|
||||||
|
.replace(/\s{3,}/g, " ")
|
||||||
|
.slice(0, 60_000); // cap at 60k chars to stay within context
|
||||||
|
} catch (err) {
|
||||||
|
return `Error fetching ${url}: ${(err as Error).message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build system prompt ───────────────────────────────────────────
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
For each product, extract:
|
||||||
|
- model: string (product name WITHOUT the brand prefix)
|
||||||
|
- category: one of [${CATEGORIES.join(", ")}]
|
||||||
|
- weightGrams: number | null (weight in grams — convert if shown in oz/lbs/kg)
|
||||||
|
- priceCents: number | null (MSRP in cents, base currency)
|
||||||
|
- priceCurrency: string (ISO currency code — "EUR" for DE brands, "USD" for US, "GBP" for GB, etc.)
|
||||||
|
- description: string | null (1-3 sentence product description)
|
||||||
|
- sourceUrl: string (direct product page URL)
|
||||||
|
- tags: string[] (from this list only: [${TAGS.join(", ")}])
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- model must NOT include the brand name (e.g., "Terrapin System" not "Revelate Designs Terrapin System")
|
||||||
|
- Only include outdoor/adventure/cycling products. Skip accessories under €5, clothing if not relevant to the target categories.
|
||||||
|
- If weight is not listed on a product page, use null — do not guess.
|
||||||
|
- Assign 2-5 relevant tags per item.
|
||||||
|
- Extract every product in their catalog, not just featured ones. Navigate to all relevant subcategories.
|
||||||
|
|
||||||
|
When done, output a JSON array of product objects as your final message. Do not wrap in markdown — raw JSON only.
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "Expedition Handlebar Pack",
|
||||||
|
"category": "bags",
|
||||||
|
"weightGrams": 300,
|
||||||
|
"priceCents": 16000,
|
||||||
|
"priceCurrency": "GBP",
|
||||||
|
"description": "14L waterproof handlebar roll bag with internal dry bag and accessory pocket.",
|
||||||
|
"sourceUrl": "https://apidura.com/shop/expedition-handlebar-pack/",
|
||||||
|
"tags": ["bikepacking", "handlebar-bag", "bike-bag"]
|
||||||
|
}
|
||||||
|
]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agentic tool-use loop ─────────────────────────────────────────
|
||||||
|
|
||||||
|
type CatalogItem = {
|
||||||
|
model: string;
|
||||||
|
category: string;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
priceCurrency: string;
|
||||||
|
description: string | null;
|
||||||
|
sourceUrl: string;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
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.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
url: { type: "string", description: "The URL to fetch" },
|
||||||
|
},
|
||||||
|
required: ["url"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const messages: Anthropic.MessageParam[] = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Crawl ${manufacturer.name}'s website at ${manufacturer.website} and extract their complete product catalog. Start with the homepage or sitemap, navigate to all product categories, and return the full product list as JSON.`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let rounds = 0;
|
||||||
|
|
||||||
|
while (rounds < MAX_TOOL_ROUNDS) {
|
||||||
|
rounds++;
|
||||||
|
console.log(` [round ${rounds}] calling model...`);
|
||||||
|
|
||||||
|
const response = await client.messages.create({
|
||||||
|
model: MODEL,
|
||||||
|
max_tokens: 8192,
|
||||||
|
system: buildSystemPrompt(manufacturer),
|
||||||
|
tools,
|
||||||
|
messages,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add assistant response to history
|
||||||
|
messages.push({ role: "assistant", content: response.content });
|
||||||
|
|
||||||
|
if (response.stop_reason === "end_turn") {
|
||||||
|
// Final message — extract JSON from text content
|
||||||
|
const textBlock = response.content.find((b) => b.type === "text");
|
||||||
|
if (!textBlock || textBlock.type !== "text") {
|
||||||
|
throw new Error("Agent finished without text output");
|
||||||
|
}
|
||||||
|
return parseAgentOutput(textBlock.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.stop_reason !== "tool_use") {
|
||||||
|
throw new Error(`Unexpected stop reason: ${response.stop_reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process tool calls
|
||||||
|
const toolResults: Anthropic.ToolResultBlockParam[] = [];
|
||||||
|
for (const block of response.content) {
|
||||||
|
if (block.type !== "tool_use") continue;
|
||||||
|
if (block.name === "fetch_page") {
|
||||||
|
const { url } = block.input as { url: string };
|
||||||
|
console.log(` [tool] fetch_page ${url}`);
|
||||||
|
const content = await fetchPage(url);
|
||||||
|
toolResults.push({
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: block.id,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({ role: "user", content: toolResults });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 parsed = JSON.parse(cleaned);
|
||||||
|
if (!Array.isArray(parsed)) throw new Error("Agent output is not a JSON array");
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upsert to GearBox API ─────────────────────────────────────────
|
||||||
|
|
||||||
|
async function upsertItems(
|
||||||
|
manufacturerSlug: string,
|
||||||
|
items: CatalogItem[],
|
||||||
|
): Promise<{ created: number; updated: number }> {
|
||||||
|
const payload = items.map((item) => ({
|
||||||
|
manufacturerSlug,
|
||||||
|
model: item.model,
|
||||||
|
category: item.category,
|
||||||
|
weightGrams: item.weightGrams ?? undefined,
|
||||||
|
priceCents: item.priceCents ?? undefined,
|
||||||
|
description: item.description ?? undefined,
|
||||||
|
sourceUrl: item.sourceUrl,
|
||||||
|
tags: item.tags,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Chunk into batches of 100 (API limit)
|
||||||
|
let totalCreated = 0;
|
||||||
|
let totalUpdated = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < payload.length; i += 100) {
|
||||||
|
const batch = payload.slice(i, i + 100);
|
||||||
|
const res = await fetch(`${GEARBOX_URL}/api/global-items/bulk`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": GEARBOX_API_KEY,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ items: batch }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Bulk upsert failed (HTTP ${res.status}): ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created: totalCreated, updated: totalUpdated };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\nCrawling manufacturer: ${manufacturerSlug}`);
|
||||||
|
if (dryRun) console.log("DRY RUN — products will not be saved\n");
|
||||||
|
|
||||||
|
const manufacturer = await fetchManufacturer(manufacturerSlug);
|
||||||
|
console.log(`Found: ${manufacturer.name} (${manufacturer.website})\n`);
|
||||||
|
|
||||||
|
console.log("Starting agent crawl...");
|
||||||
|
const items = await runCrawlAgent(manufacturer);
|
||||||
|
console.log(`\nAgent extracted ${items.length} products`);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log("\nDry run output (first 3 items):");
|
||||||
|
console.log(JSON.stringify(items.slice(0, 3), null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nUpserting to catalog...");
|
||||||
|
const { created, updated } = await upsertItems(manufacturerSlug, items);
|
||||||
|
console.log(`\nDone: ${created} created, ${updated} updated`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add market prices upsert after the bulk upsert**
|
||||||
|
|
||||||
|
After `upsertItems`, add a call to also upsert `marketPrices` for each item that has a price. This requires knowing the item IDs returned from the bulk upsert and the manufacturer's country/currency. Add this helper after `upsertItems`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function upsertMarketPrices(
|
||||||
|
globalItemIds: number[],
|
||||||
|
items: CatalogItem[],
|
||||||
|
): Promise<void> {
|
||||||
|
for (let i = 0; i < globalItemIds.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
const globalItemId = globalItemIds[i];
|
||||||
|
if (!item?.priceCents || !globalItemId) continue;
|
||||||
|
|
||||||
|
// Derive market from currency
|
||||||
|
const market = item.priceCurrency === "EUR" ? "EU"
|
||||||
|
: item.priceCurrency === "USD" ? "US"
|
||||||
|
: item.priceCurrency === "GBP" ? "GB"
|
||||||
|
: item.priceCurrency;
|
||||||
|
|
||||||
|
await fetch(`${GEARBOX_URL}/api/global-items/${globalItemId}/market-prices`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": GEARBOX_API_KEY,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
market,
|
||||||
|
currency: item.priceCurrency,
|
||||||
|
priceCents: item.priceCents,
|
||||||
|
source: "manufacturer-crawl",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Call `upsertMarketPrices` in `main()` after the bulk upsert, passing the item IDs from the API response.
|
||||||
|
|
||||||
|
Note: The bulk upsert response returns `items[]` with IDs. Store those and pass them here. Update the `upsertItems` function return type to also return `itemIds: number[]`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/crawl-manufacturer.ts
|
||||||
|
git commit -m "feat: crawl-manufacturer agent script — Haiku tool-use loop + bulk upsert"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Batch runner
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/crawl-all.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `scripts/crawl-all.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Crawl all active manufacturers of a given tier.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run scripts/crawl-all.ts --tier=1
|
||||||
|
* bun run scripts/crawl-all.ts --tier=1 --dry-run
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GEARBOX_URL = process.env.GEARBOX_URL ?? "http://localhost:3000";
|
||||||
|
const GEARBOX_API_KEY = process.env.GEARBOX_API_KEY ?? "";
|
||||||
|
|
||||||
|
const args = Object.fromEntries(
|
||||||
|
process.argv
|
||||||
|
.slice(2)
|
||||||
|
.filter((a) => a.startsWith("--"))
|
||||||
|
.map((a) => {
|
||||||
|
const [k, v] = a.slice(2).split("=");
|
||||||
|
return [k, v ?? "true"];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tier = args["tier"] ? Number(args["tier"]) : 1;
|
||||||
|
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 }>;
|
||||||
|
return all.filter((m) => m.active && m.tier === targetTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!GEARBOX_API_KEY) {
|
||||||
|
console.error("GEARBOX_API_KEY env var is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manufacturers = await listActiveManufacturers(tier);
|
||||||
|
console.log(`Found ${manufacturers.length} active tier-${tier} manufacturers\n`);
|
||||||
|
|
||||||
|
const results: Array<{ slug: string; status: "ok" | "error"; error?: string }> = [];
|
||||||
|
|
||||||
|
for (const m of manufacturers) {
|
||||||
|
console.log(`\n${"─".repeat(50)}`);
|
||||||
|
console.log(`Crawling: ${m.name} (${m.slug})`);
|
||||||
|
try {
|
||||||
|
const extraArgs = dryRun ? ["--dry-run"] : [];
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["bun", "run", "scripts/crawl-manufacturer.ts", `--manufacturer=${m.slug}`, ...extraArgs],
|
||||||
|
{ stdout: "inherit", stderr: "inherit", env: process.env },
|
||||||
|
);
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
if (exitCode !== 0) throw new Error(`Exited with code ${exitCode}`);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${"═".repeat(50)}`);
|
||||||
|
console.log("Summary:");
|
||||||
|
for (const r of results) {
|
||||||
|
const icon = r.status === "ok" ? "✓" : "✗";
|
||||||
|
console.log(` ${icon} ${r.slug}${r.error ? ` — ${r.error}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = results.filter((r) => r.status === "error");
|
||||||
|
if (failed.length > 0) {
|
||||||
|
console.error(`\n${failed.length} manufacturer(s) failed`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/crawl-all.ts
|
||||||
|
git commit -m "feat: crawl-all batch runner — iterate active manufacturers by tier"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Package.json scripts + smoke test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add scripts to `package.json`**
|
||||||
|
|
||||||
|
In the `"scripts"` section, add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"db:crawl": "bun run scripts/crawl-manufacturer.ts",
|
||||||
|
"db:crawl-all": "bun run scripts/crawl-all.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json
|
||||||
|
git commit -m "chore: add db:crawl and db:crawl-all npm scripts"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Smoke test with dry run**
|
||||||
|
|
||||||
|
Ensure GearBox is running (`bun run dev:server` in another terminal) and the manufacturer exists in the DB.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GEARBOX_API_KEY=<your-api-key> ANTHROPIC_API_KEY=<your-key> \
|
||||||
|
bun run db:crawl --manufacturer=apidura --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
Crawling manufacturer: apidura
|
||||||
|
Found: Apidura (https://apidura.com)
|
||||||
|
|
||||||
|
Starting agent crawl...
|
||||||
|
[round 1] calling model...
|
||||||
|
[tool] fetch_page https://apidura.com
|
||||||
|
...
|
||||||
|
Agent extracted N products
|
||||||
|
|
||||||
|
Dry run output (first 3 items):
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "...",
|
||||||
|
"category": "bags",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Live run against one manufacturer**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GEARBOX_API_KEY=<your-api-key> ANTHROPIC_API_KEY=<your-key> \
|
||||||
|
bun run db:crawl --manufacturer=apidura
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: products appear in the catalog. Verify by opening the app catalog search or calling `GET /api/global-items?q=apidura`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit any adjustments**
|
||||||
|
|
||||||
|
If the agent prompt needed tuning (category mapping issues, extra noise in output), update `buildSystemPrompt` in `crawl-manufacturer.ts` and commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/crawl-manufacturer.ts
|
||||||
|
git commit -m "fix: tune agent prompt for cleaner category/tag extraction"
|
||||||
|
```
|
||||||
1355
docs/superpowers/plans/2026-04-18-catalog-schema-migration.md
Normal file
1355
docs/superpowers/plans/2026-04-18-catalog-schema-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -72,7 +72,11 @@ export function AddToCollectionModal() {
|
|||||||
closeAddToCollection();
|
closeAddToCollection();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setError(err instanceof Error ? err.message : t("collection:addToCollection.failedToAdd"));
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: t("collection:addToCollection.failedToAdd"),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -138,7 +142,9 @@ export function AddToCollectionModal() {
|
|||||||
type="number"
|
type="number"
|
||||||
value={purchasePrice}
|
value={purchasePrice}
|
||||||
onChange={(e) => setPurchasePrice(e.target.value)}
|
onChange={(e) => setPurchasePrice(e.target.value)}
|
||||||
placeholder={t("collection:addToCollection.purchasePricePlaceholder")}
|
placeholder={t(
|
||||||
|
"collection:addToCollection.purchasePricePlaceholder",
|
||||||
|
)}
|
||||||
min="0"
|
min="0"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
@@ -160,7 +166,9 @@ export function AddToCollectionModal() {
|
|||||||
disabled={createItem.isPending}
|
disabled={createItem.isPending}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{createItem.isPending ? t("collection:addToCollection.addingButton") : t("collection:addToCollection.addButton")}
|
{createItem.isPending
|
||||||
|
? t("collection:addToCollection.addingButton")
|
||||||
|
: t("collection:addToCollection.addButton")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ export function AddToThreadModal() {
|
|||||||
toast.success(`Added to "${thread?.name ?? "thread"}"`);
|
toast.success(`Added to "${thread?.name ?? "thread"}"`);
|
||||||
closeAddToThread();
|
closeAddToThread();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("addToThread.failedToAdd"));
|
setError(
|
||||||
|
err instanceof Error ? err.message : t("addToThread.failedToAdd"),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -144,7 +146,9 @@ export function AddToThreadModal() {
|
|||||||
toast.success(`Created "${trimmedName}" with first candidate`);
|
toast.success(`Created "${trimmedName}" with first candidate`);
|
||||||
closeAddToThread();
|
closeAddToThread();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("addToThread.failedToCreate"));
|
setError(
|
||||||
|
err instanceof Error ? err.message : t("addToThread.failedToCreate"),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -175,7 +179,9 @@ export function AddToThreadModal() {
|
|||||||
onKeyDown={() => {}}
|
onKeyDown={() => {}}
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">
|
<h2 className="text-lg font-semibold text-gray-900 mb-1">
|
||||||
{mode === "pick" ? t("addToThread.title") : t("addToThread.newThreadTitle")}
|
{mode === "pick"
|
||||||
|
? t("addToThread.title")
|
||||||
|
: t("addToThread.newThreadTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500 mb-4">{globalItemName}</p>
|
<p className="text-sm text-gray-500 mb-4">{globalItemName}</p>
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ export function CategoryFilterDropdown({
|
|||||||
<span className="text-gray-900">{selectedCategory.name}</span>
|
<span className="text-gray-900">{selectedCategory.name}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-600">{t("categoryFilter.allCategories")}</span>
|
<span className="text-gray-600">
|
||||||
|
{t("categoryFilter.allCategories")}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{selectedCategory ? (
|
{selectedCategory ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ export function CategoryHeader({
|
|||||||
<LucideIcon name={icon} size={22} className="text-gray-500" />
|
<LucideIcon name={icon} size={22} className="text-gray-500" />
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
|
||||||
<span className="text-sm text-gray-400">
|
<span className="text-sm text-gray-400">
|
||||||
{t("categoryHeader.itemCount", { count: itemCount })} · {weight(totalWeight)}{" "}
|
{t("categoryHeader.itemCount", { count: itemCount })} ·{" "}
|
||||||
· {price(totalCost)}
|
{weight(totalWeight)} · {price(totalCost)}
|
||||||
</span>
|
</span>
|
||||||
{!isUncategorized && (
|
{!isUncategorized && (
|
||||||
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
|||||||
@@ -235,7 +235,9 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
|
|||||||
disabled={createCategory.isPending}
|
disabled={createCategory.isPending}
|
||||||
className="text-xs font-medium text-gray-600 hover:text-gray-800 disabled:opacity-50"
|
className="text-xs font-medium text-gray-600 hover:text-gray-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{createCategory.isPending ? "..." : t("categoryPicker.create")}
|
{createCategory.isPending
|
||||||
|
? "..."
|
||||||
|
: t("categoryPicker.create")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -137,14 +137,18 @@ export function CollectionView() {
|
|||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<LucideIcon name="layers" size={14} className="text-gray-400" />
|
<LucideIcon name="layers" size={14} className="text-gray-400" />
|
||||||
<span className="text-xs text-gray-500">{t("common:stats.items")}</span>
|
<span className="text-xs text-gray-500">
|
||||||
|
{t("common:stats.items")}
|
||||||
|
</span>
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
{totals.global.itemCount}
|
{totals.global.itemCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<LucideIcon name="weight" size={14} className="text-gray-400" />
|
<LucideIcon name="weight" size={14} className="text-gray-400" />
|
||||||
<span className="text-xs text-gray-500">{t("common:stats.totalWeight")}</span>
|
<span className="text-xs text-gray-500">
|
||||||
|
{t("common:stats.totalWeight")}
|
||||||
|
</span>
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
{weight(totals.global.totalWeight)}
|
{weight(totals.global.totalWeight)}
|
||||||
</span>
|
</span>
|
||||||
@@ -155,7 +159,9 @@ export function CollectionView() {
|
|||||||
size={14}
|
size={14}
|
||||||
className="text-gray-400"
|
className="text-gray-400"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-500">{t("common:stats.totalSpent")}</span>
|
<span className="text-xs text-gray-500">
|
||||||
|
{t("common:stats.totalSpent")}
|
||||||
|
</span>
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
{price(totals.global.totalCost)}
|
{price(totals.global.totalCost)}
|
||||||
</span>
|
</span>
|
||||||
@@ -205,7 +211,10 @@ export function CollectionView() {
|
|||||||
</div>
|
</div>
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
{t("common:filter.showing", { filtered: filteredItems.length, total: items.length })}
|
{t("common:filter.showing", {
|
||||||
|
filtered: filteredItems.length,
|
||||||
|
total: items.length,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ export function CreateThreadModal() {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={() => {}}
|
onKeyDown={() => {}}
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t("create.title")}</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
{t("create.title")}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -135,7 +137,9 @@ export function CreateThreadModal() {
|
|||||||
disabled={createThread.isPending}
|
disabled={createThread.isPending}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{createThread.isPending ? t("common:actions.creating") : t("create.createThread")}
|
{createThread.isPending
|
||||||
|
? t("common:actions.creating")
|
||||||
|
: t("create.createThread")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ export function ItemPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SlideOutPanel isOpen={isOpen} onClose={onClose} title={t("collection:itemPicker.title")}>
|
<SlideOutPanel
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("collection:itemPicker.title")}
|
||||||
|
>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 overflow-y-auto -mx-6 px-6">
|
<div className="flex-1 overflow-y-auto -mx-6 px-6">
|
||||||
{!items || items.length === 0 ? (
|
{!items || items.length === 0 ? (
|
||||||
@@ -146,7 +150,9 @@ export function ItemPicker({
|
|||||||
disabled={syncItems.isPending}
|
disabled={syncItems.isPending}
|
||||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{syncItems.isPending ? t("common:actions.saving") : t("collection:itemPicker.done")}
|
{syncItems.isPending
|
||||||
|
? t("common:actions.saving")
|
||||||
|
: t("collection:itemPicker.done")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -167,7 +167,9 @@ export function LinkToGlobalItem({
|
|||||||
<div className="border-t border-gray-100 max-h-48 overflow-y-auto">
|
<div className="border-t border-gray-100 max-h-48 overflow-y-auto">
|
||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
<div className="p-3 text-center">
|
<div className="p-3 text-center">
|
||||||
<span className="text-xs text-gray-400">{t("linkToGlobal.searching")}</span>
|
<span className="text-xs text-gray-400">
|
||||||
|
{t("linkToGlobal.searching")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : searchResults && searchResults.length > 0 ? (
|
) : searchResults && searchResults.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -197,7 +199,9 @@ export function LinkToGlobalItem({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-3 text-center">
|
<div className="p-3 text-center">
|
||||||
<span className="text-xs text-gray-400">{t("linkToGlobal.noItemsFound")}</span>
|
<span className="text-xs text-gray-400">
|
||||||
|
{t("linkToGlobal.noItemsFound")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ export function ManualEntryForm({
|
|||||||
onSuccess(item.name);
|
onSuccess(item.name);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setError(err instanceof Error ? err.message : t("collection:manualEntry.failedToSave"));
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: t("collection:manualEntry.failedToSave"),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -223,7 +227,9 @@ export function ManualEntryForm({
|
|||||||
disabled={createItem.isPending || !name.trim() || categoryId === null}
|
disabled={createItem.isPending || !name.trim() || categoryId === null}
|
||||||
className="w-full px-4 py-2.5 text-sm font-medium text-white bg-gray-900 rounded-lg hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
className="w-full px-4 py-2.5 text-sm font-medium text-white bg-gray-900 rounded-lg hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{createItem.isPending ? t("common:actions.saving") : t("collection:manualEntry.addToCollection")}
|
{createItem.isPending
|
||||||
|
? t("common:actions.saving")
|
||||||
|
: t("collection:manualEntry.addToCollection")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ export function PlanningView() {
|
|||||||
1
|
1
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">{t("threads:planning.step1Title")}</p>
|
<p className="font-medium text-gray-900">
|
||||||
|
{t("threads:planning.step1Title")}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{t("threads:planning.step1Description")}
|
{t("threads:planning.step1Description")}
|
||||||
</p>
|
</p>
|
||||||
@@ -128,7 +130,9 @@ export function PlanningView() {
|
|||||||
2
|
2
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">{t("threads:planning.step2Title")}</p>
|
<p className="font-medium text-gray-900">
|
||||||
|
{t("threads:planning.step2Title")}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{t("threads:planning.step2Description")}
|
{t("threads:planning.step2Description")}
|
||||||
</p>
|
</p>
|
||||||
@@ -139,7 +143,9 @@ export function PlanningView() {
|
|||||||
3
|
3
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">{t("threads:planning.step3Title")}</p>
|
<p className="font-medium text-gray-900">
|
||||||
|
{t("threads:planning.step3Title")}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{t("threads:planning.step3Description")}
|
{t("threads:planning.step3Description")}
|
||||||
</p>
|
</p>
|
||||||
@@ -171,7 +177,9 @@ export function PlanningView() {
|
|||||||
</div>
|
</div>
|
||||||
) : filteredThreads.length === 0 ? (
|
) : filteredThreads.length === 0 ? (
|
||||||
<div className="py-12 text-center">
|
<div className="py-12 text-center">
|
||||||
<p className="text-sm text-gray-500">{t("threads:empty.noThreads")}</p>
|
<p className="text-sm text-gray-500">
|
||||||
|
{t("threads:empty.noThreads")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ export function ProfileSection() {
|
|||||||
setDirty(true);
|
setDirty(true);
|
||||||
} catch {
|
} catch {
|
||||||
setAvatarDisplayUrl(null);
|
setAvatarDisplayUrl(null);
|
||||||
setMessage({ type: "error", text: t("collection:profileSection.avatarUploadFailed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("collection:profileSection.avatarUploadFailed"),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
@@ -87,7 +90,9 @@ export function ProfileSection() {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSave} className="space-y-4">
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">{t("collection:profileSection.title")}</h3>
|
<h3 className="text-sm font-medium text-gray-900">
|
||||||
|
{t("collection:profileSection.title")}
|
||||||
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
{t("collection:profileSection.subtitle")}
|
{t("collection:profileSection.subtitle")}
|
||||||
</p>
|
</p>
|
||||||
@@ -150,7 +155,9 @@ export function ProfileSection() {
|
|||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
className="text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
className="text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
||||||
>
|
>
|
||||||
{uploading ? t("collection:profileSection.uploadingAvatar") : t("collection:profileSection.changeAvatar")}
|
{uploading
|
||||||
|
? t("collection:profileSection.uploadingAvatar")
|
||||||
|
: t("collection:profileSection.changeAvatar")}
|
||||||
</button>
|
</button>
|
||||||
{avatarFilename && (
|
{avatarFilename && (
|
||||||
<button
|
<button
|
||||||
@@ -235,7 +242,9 @@ export function ProfileSection() {
|
|||||||
disabled={updateProfile.isPending}
|
disabled={updateProfile.isPending}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{updateProfile.isPending ? t("common:actions.saving") : t("collection:profileSection.saveProfile")}
|
{updateProfile.isPending
|
||||||
|
? t("common:actions.saving")
|
||||||
|
: t("collection:profileSection.saveProfile")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export function SetupsView() {
|
|||||||
disabled={!newSetupName.trim() || createSetup.isPending}
|
disabled={!newSetupName.trim() || createSetup.isPending}
|
||||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{createSetup.isPending ? t("setups:creating") : t("common:actions.create")}
|
{createSetup.isPending
|
||||||
|
? t("setups:creating")
|
||||||
|
: t("common:actions.create")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -61,7 +63,9 @@ export function SetupsView() {
|
|||||||
1
|
1
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">{t("setups:emptyState.step1Title")}</p>
|
<p className="font-medium text-gray-900">
|
||||||
|
{t("setups:emptyState.step1Title")}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{t("setups:emptyState.step1Description")}
|
{t("setups:emptyState.step1Description")}
|
||||||
</p>
|
</p>
|
||||||
@@ -72,7 +76,9 @@ export function SetupsView() {
|
|||||||
2
|
2
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">{t("setups:emptyState.step2Title")}</p>
|
<p className="font-medium text-gray-900">
|
||||||
|
{t("setups:emptyState.step2Title")}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{t("setups:emptyState.step2Description")}
|
{t("setups:emptyState.step2Description")}
|
||||||
</p>
|
</p>
|
||||||
@@ -83,7 +89,9 @@ export function SetupsView() {
|
|||||||
3
|
3
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">{t("setups:emptyState.step3Title")}</p>
|
<p className="font-medium text-gray-900">
|
||||||
|
{t("setups:emptyState.step3Title")}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{t("setups:emptyState.step3Description")}
|
{t("setups:emptyState.step3Description")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ export function StatusBadge({ status, onStatusChange }: StatusBadgeProps) {
|
|||||||
}}
|
}}
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors cursor-pointer"
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<LucideIcon name={STATUS_ICONS[status]} size={14} className="text-gray-500" />
|
<LucideIcon
|
||||||
|
name={STATUS_ICONS[status]}
|
||||||
|
size={14}
|
||||||
|
className="text-gray-500"
|
||||||
|
/>
|
||||||
{STATUS_LABELS[status]}
|
{STATUS_LABELS[status]}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Cell,
|
Cell,
|
||||||
Label,
|
Label,
|
||||||
@@ -7,7 +8,6 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import type { SetupItemWithCategory } from "../hooks/useSetups";
|
import type { SetupItemWithCategory } from "../hooks/useSetups";
|
||||||
import { formatWeight, type WeightUnit } from "../lib/formatters";
|
import { formatWeight, type WeightUnit } from "../lib/formatters";
|
||||||
@@ -205,7 +205,9 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
|||||||
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
||||||
{/* Header with pill toggle */}
|
{/* Header with pill toggle */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-medium text-gray-700">{t("weightSummary.title")}</h3>
|
<h3 className="text-sm font-medium text-gray-700">
|
||||||
|
{t("weightSummary.title")}
|
||||||
|
</h3>
|
||||||
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
|
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
|
||||||
{VIEW_MODES.map((mode) => (
|
{VIEW_MODES.map((mode) => (
|
||||||
<button
|
<button
|
||||||
@@ -218,7 +220,9 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
|||||||
: "text-gray-400 hover:text-gray-600"
|
: "text-gray-400 hover:text-gray-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{mode === "category" ? t("weightSummary.category") : t("weightSummary.classification")}
|
{mode === "category"
|
||||||
|
? t("weightSummary.category")
|
||||||
|
: t("weightSummary.classification")}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { ArrowLeft, LayoutGrid, LayoutList, X } from "lucide-react";
|
import { ArrowLeft, LayoutGrid, LayoutList, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { GearImage } from "../../components/GearImage";
|
import { GearImage } from "../../components/GearImage";
|
||||||
import { GlobalItemCard } from "../../components/GlobalItemCard";
|
import { GlobalItemCard } from "../../components/GlobalItemCard";
|
||||||
@@ -663,9 +663,7 @@ function EmptyState({ hasQuery }: { hasQuery: boolean }) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-sm text-gray-500 text-center">
|
<p className="text-sm text-gray-500 text-center">
|
||||||
{hasQuery
|
{hasQuery ? t("empty.noResults") : t("empty.noCatalogItems")}
|
||||||
? t("empty.noResults")
|
|
||||||
: t("empty.noCatalogItems")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ function PopularSetupsSection() {
|
|||||||
return (
|
return (
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t("home.popularSetups")}</h2>
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
{t("home.popularSetups")}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<SectionSkeleton count={6} aspect="none" />
|
<SectionSkeleton count={6} aspect="none" />
|
||||||
@@ -57,7 +59,9 @@ function RecentItemsSection() {
|
|||||||
return (
|
return (
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t("home.recentlyAdded")}</h2>
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
{t("home.recentlyAdded")}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<SectionSkeleton count={8} aspect="[4/3]" />
|
<SectionSkeleton count={8} aspect="[4/3]" />
|
||||||
|
|||||||
@@ -207,7 +207,9 @@ function ItemDetail() {
|
|||||||
search: shareToken ? { share: shareToken } : {},
|
search: shareToken ? { share: shareToken } : {},
|
||||||
}
|
}
|
||||||
: { to: "/collection" as const, params: {}, search: {} };
|
: { to: "/collection" as const, params: {}, search: {} };
|
||||||
const backLabel = setupId ? t("collection:item.backToSetup") : t("collection:item.backToCollection");
|
const backLabel = setupId
|
||||||
|
? t("collection:item.backToSetup")
|
||||||
|
: t("collection:item.backToCollection");
|
||||||
|
|
||||||
if (error || !item) {
|
if (error || !item) {
|
||||||
return (
|
return (
|
||||||
@@ -221,7 +223,9 @@ function ItemDetail() {
|
|||||||
← {backLabel}
|
← {backLabel}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<p className="text-sm text-gray-500">{t("collection:item.notFound")}</p>
|
<p className="text-sm text-gray-500">
|
||||||
|
{t("collection:item.notFound")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -270,15 +274,25 @@ function ItemDetail() {
|
|||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="hidden md:inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
className="hidden md:inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{isReference ? t("collection:item.removeFromCollection") : t("common:actions.delete")}
|
{isReference
|
||||||
|
? t("collection:item.removeFromCollection")
|
||||||
|
: t("common:actions.delete")}
|
||||||
</button>
|
</button>
|
||||||
{/* Delete — mobile */}
|
{/* Delete — mobile */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
aria-label={isReference ? t("collection:item.removeFromCollection") : t("common:actions.delete")}
|
aria-label={
|
||||||
title={isReference ? t("collection:item.removeFromCollection") : t("common:actions.delete")}
|
isReference
|
||||||
|
? t("collection:item.removeFromCollection")
|
||||||
|
: t("common:actions.delete")
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
isReference
|
||||||
|
? t("collection:item.removeFromCollection")
|
||||||
|
: t("common:actions.delete")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<LucideIcon name="trash-2" size={16} />
|
<LucideIcon name="trash-2" size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -317,7 +331,9 @@ function ItemDetail() {
|
|||||||
disabled={updateItem.isPending || !form.name.trim()}
|
disabled={updateItem.isPending || !form.name.trim()}
|
||||||
className="px-4 py-1.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors disabled:opacity-50"
|
className="px-4 py-1.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{updateItem.isPending ? t("common:actions.saving") : t("common:actions.save")}
|
{updateItem.isPending
|
||||||
|
? t("common:actions.saving")
|
||||||
|
: t("common:actions.save")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ function ProfilePage() {
|
|||||||
>
|
>
|
||||||
← {t("actions.back")}
|
← {t("actions.back")}
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-xl font-semibold text-gray-900">{t("profile.title")}</h1>
|
<h1 className="text-xl font-semibold text-gray-900">
|
||||||
|
{t("profile.title")}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 1: Profile Info (D-02) */}
|
{/* Section 1: Profile Info (D-02) */}
|
||||||
@@ -108,14 +110,20 @@ function AccountInfoSection({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">{t("profile.account")}</h3>
|
<h3 className="text-sm font-medium text-gray-900">
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{t("profile.accountInfo")}</p>
|
{t("profile.account")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{t("profile.accountInfo")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email row */}
|
{/* Email row */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-gray-700">{t("profile.email")}</span>
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{t("profile.email")}
|
||||||
|
</span>
|
||||||
<span className="text-sm text-gray-900 ml-3">
|
<span className="text-sm text-gray-900 ml-3">
|
||||||
{email || t("profile.noEmail")}
|
{email || t("profile.noEmail")}
|
||||||
</span>
|
</span>
|
||||||
@@ -147,7 +155,9 @@ function AccountInfoSection({
|
|||||||
disabled={changeEmail.isPending}
|
disabled={changeEmail.isPending}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{changeEmail.isPending ? t("profile.updating") : t("profile.updateEmail")}
|
{changeEmail.isPending
|
||||||
|
? t("profile.updating")
|
||||||
|
: t("profile.updateEmail")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -232,8 +242,12 @@ function SecuritySection() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">{t("profile.security")}</h3>
|
<h3 className="text-sm font-medium text-gray-900">
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{t("profile.managePassword")}</p>
|
{t("profile.security")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{t("profile.managePassword")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
@@ -335,7 +349,9 @@ function DangerZoneSection() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">{t("profile.dangerZone")}</h3>
|
<h3 className="text-sm font-medium text-gray-900">
|
||||||
|
{t("profile.dangerZone")}
|
||||||
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
{t("profile.dangerZoneDescription")}
|
{t("profile.dangerZoneDescription")}
|
||||||
</p>
|
</p>
|
||||||
@@ -378,7 +394,9 @@ function DangerZoneSection() {
|
|||||||
disabled={confirmation !== "DELETE" || deleteAccount.isPending}
|
disabled={confirmation !== "DELETE" || deleteAccount.isPending}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
|
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{deleteAccount.isPending ? t("actions.deleting") : t("profile.deleteAccount")}
|
{deleteAccount.isPending
|
||||||
|
? t("actions.deleting")
|
||||||
|
: t("profile.deleteAccount")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -158,7 +158,9 @@ function SetupDetailPage() {
|
|||||||
{isSharedView && setup && (
|
{isSharedView && setup && (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border-b border-blue-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8">
|
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border-b border-blue-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8">
|
||||||
<LucideIcon name="link" size={16} className="text-blue-500" />
|
<LucideIcon name="link" size={16} className="text-blue-500" />
|
||||||
<span className="text-sm text-blue-700">{t("setups:detail.sharedSetup")}</span>
|
<span className="text-sm text-blue-700">
|
||||||
|
{t("setups:detail.sharedSetup")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -448,7 +450,9 @@ function SetupDetailPage() {
|
|||||||
disabled={deleteSetup.isPending}
|
disabled={deleteSetup.isPending}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
|
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{deleteSetup.isPending ? t("common:actions.deleting") : t("common:actions.delete")}
|
{deleteSetup.isPending
|
||||||
|
? t("common:actions.deleting")
|
||||||
|
: t("common:actions.delete")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ function ThreadDetailPage() {
|
|||||||
: "bg-gray-100 text-gray-500"
|
: "bg-gray-100 text-gray-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isActive ? t("threads:detail.statusActive") : t("threads:detail.statusResolved")}
|
{isActive
|
||||||
|
? t("threads:detail.statusActive")
|
||||||
|
: t("threads:detail.statusResolved")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,7 +421,9 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
|
|||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t("threads:detail.addCandidateModal.title")}</h2>
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
{t("threads:detail.addCandidateModal.title")}
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -617,7 +621,9 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
|
|||||||
disabled={createCandidate.isPending}
|
disabled={createCandidate.isPending}
|
||||||
className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{createCandidate.isPending ? t("threads:detail.addCandidateModal.adding") : t("threads:detail.addCandidateModal.submit")}
|
{createCandidate.isPending
|
||||||
|
? t("threads:detail.addCandidateModal.adding")
|
||||||
|
: t("threads:detail.addCandidateModal.submit")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user