style(i18n): fix lint — formatting and import ordering across 21 files
All checks were successful
CI / ci (push) Successful in 1m21s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 1m15s

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:
2026-04-18 14:49:10 +02:00
parent 1b2ddcd0bd
commit bea386e7db
23 changed files with 2192 additions and 64 deletions

View 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"
```

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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]" />

View File

@@ -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() {
&larr; {backLabel} &larr; {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>
)} )}

View File

@@ -38,7 +38,9 @@ function ProfilePage() {
> >
&larr; {t("actions.back")} &larr; {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>

View File

@@ -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>

View File

@@ -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"