Compare commits
37 Commits
9318bc56ac
...
v2.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 2853477a75 | |||
| 92b84d2cd6 | |||
| ebf031a62c | |||
| 03e0fe99fa | |||
| adbc13eb15 | |||
| 2beabe88f9 | |||
| 29f925027c | |||
| 32fa261ec2 | |||
| 9864a09fc1 | |||
| c3874d031a | |||
| cd55f3c282 | |||
| 80f4d1d9ae | |||
| ba13fa8ded | |||
| 13883ea14d | |||
| bedef04581 | |||
| c1177764ef | |||
| ded6bf521e | |||
| d91d32deaf | |||
| c98ac6e46f | |||
| e536f68bd1 | |||
| 80cb313b08 | |||
| 159ff824b2 | |||
| 09952e37b4 | |||
| fe5bd49b75 | |||
| ef531f79b2 | |||
| 6108db3dab | |||
| af58145fe1 | |||
| b647e23f91 | |||
| 62916a8397 | |||
| 596872d942 | |||
| da5ce7da1d | |||
| 452928760a | |||
| 957d661567 | |||
| e3124e49c9 | |||
| 581872b534 | |||
| ce48121b2b | |||
| 2948cc5848 |
@@ -1,5 +1,68 @@
|
||||
# Milestones
|
||||
|
||||
## v2.2 User Experience Polish (Shipped: 2026-04-13)
|
||||
|
||||
**Phases completed:** 36 phases, 68 plans, 120 tasks
|
||||
|
||||
**Key accomplishments:**
|
||||
|
||||
- Parameterized formatWeight with g/oz/lb/kg conversion and useWeightUnit settings hook, backed by 21 TDD tests
|
||||
- Segmented g/oz/lb/kg toggle in TotalsBar with all 8 weight display call sites wired to user-selected unit
|
||||
- Candidate status tracking (researching/ordered/arrived) with schema migration, service/Zod updates, 5 TDD tests, and clickable StatusBadge popup on CandidateCard
|
||||
- Sticky search/filter toolbar on gear tab with text+category filtering, and shared icon-aware CategoryFilterDropdown on both gear and planning tabs
|
||||
- Per-setup item classification (base/worn/consumable) with click-to-cycle badge, classification-preserving sync, and full test coverage
|
||||
- Recharts donut chart with category/classification toggle, weight subtotals card, and hover tooltips inside setup detail page
|
||||
- Nullable pros/cons TEXT columns added to thread_candidates from SQLite schema through Drizzle migration, service layer, Zod validation, React form inputs, and CandidateCard visual badge
|
||||
- sortOrder REAL column, reorderCandidates transaction service, and PATCH /api/threads/:id/candidates/reorder endpoint with active-thread guard
|
||||
- 1. [Rule 2 - Missing] Added pros/cons fields to CandidateWithCategory in useThreads.ts
|
||||
- Side-by-side candidate comparison table with sticky labels, weight/price delta highlighting, and resolved-thread winner marking via a new "compare" candidateViewMode
|
||||
- PostgreSQL schema with 13 pgTable definitions, postgres.js connection, PGlite test infrastructure, and initial migration
|
||||
- PostgreSQL 16 Docker Compose for dev and production, lean Dockerfile without native SQLite build dependencies
|
||||
- All 9 service files (30 functions) converted from synchronous SQLite to async PostgreSQL operations with PGlite smoke test validation
|
||||
- All 9 route files and auth middleware converted to properly await async service/DB calls, preventing Promise-as-JSON responses
|
||||
- One-time data migration script converting all 13 tables from SQLite to PostgreSQL with timestamp/boolean type conversions and serial sequence reset
|
||||
- All 18 test files converted to async PGlite with 161 tests passing across service, route, and MCP layers
|
||||
- Logto OIDC provider added to Docker Compose with Postgres init script, users/sessions tables removed from schema
|
||||
- Three-way auth middleware with @hono/oidc-auth for browser sessions, API keys for programmatic access, and MCP OAuth consent flow
|
||||
- OIDC login redirect page, cleaned auth hooks (string user id, no credential forms), API-key E2E seed, and three-way auth test coverage
|
||||
- pgTable schema with users table, userId FK on 6 entity tables, composite constraints, and auth middleware resolving userId for all auth methods
|
||||
- All 7 service files accept userId parameter with and(eq) isolation on every query — no unscoped reads or writes remain
|
||||
- Complete userId propagation chain from auth middleware through routes and MCP tools to service layer
|
||||
- Route tests, MCP tests, and cross-user isolation tests updated with userId context for multi-user data model
|
||||
- S3 storage abstraction with uploadImage/deleteImage/getImageUrl using @aws-sdk/client-s3, plus MinIO in Docker Compose with automatic bucket creation
|
||||
- Replaced all local filesystem image operations with S3 storage service calls across routes, services, and MCP tools
|
||||
- Replaced all client /uploads/ path references with presigned S3 URLs and created one-time image migration script
|
||||
- Global items table, item-global links, user profile columns, setup visibility, Zod schemas, and 18-item bikepacking seed catalog
|
||||
- Global item catalog backend with LIKE search, owner count aggregation, item linking, idempotent seeding, and full test coverage
|
||||
- Profile service with CRUD and public profile data, public setup viewing, setup visibility toggle, and auth middleware bypass for public endpoints
|
||||
- Global catalog browse/search page, item detail with owner count, and link-to-catalog component using TanStack Router and Query
|
||||
- Profile edit UI in settings with avatar upload, public profile page with setup listing, and setup visibility toggle with globe icon
|
||||
- Database schema updated with direct globalItemId FK on items/candidates, tags system tables, and data migration from itemGlobalLinks
|
||||
- COALESCE merge pattern in item/thread services for transparent reference item data, branched thread resolution, and link/unlink endpoint removal
|
||||
- Tag-filtered global item search with AND logic, owner count via direct FK, and COALESCE merge propagated to setup/totals/profile/CSV services
|
||||
- Tags endpoint with alphabetical ordering, global-items route registration, UIStore FAB/catalog-search state, and tag-aware useGlobalItems hook
|
||||
- Global FAB with animated mini menu and full-screen catalog search overlay with debounced search, tag chip AND-filtering, and result card grid
|
||||
- Private item detail page with edit mode toggle at /items/:id, and enhanced catalog detail page with Add to Collection stub button
|
||||
- Candidate detail page with edit mode toggle at /threads/:threadId/candidates/:candidateId, thread route directory restructured for nested routes, add-candidate modal replacing slide-out panel
|
||||
- All card components rewired from slide-out panels to detail page navigation, panels removed from root layout, UIStore cleaned of panel state
|
||||
- AddToCollectionModal with category/notes/price fields, sonner toasts, and wired catalog search + detail page entry points
|
||||
- AddToThreadModal with existing thread picker, new thread + candidate creation, and session thread memory for catalog search flow
|
||||
- ManualEntryForm component with CategoryPicker, ImageUpload, and cents conversion wired into CatalogSearchOverlay as inline mode with entry points, success card, and context-sensitive navigation
|
||||
- createRateLimit(max, windowMs) factory with browse (120/min) and detail (60/min) tiers applied to all public GET endpoints before auth middleware
|
||||
- globalItems attribution columns (sourceUrl, imageCredit, imageSourceUrl) with unique(brand, model) constraint, upsertGlobalItem/bulkUpsertGlobalItems service functions, and Zod schemas — 21 tests passing
|
||||
- POST /api/global-items, POST /api/global-items/bulk, upsert_catalog_item and bulk_upsert_catalog MCP tools, and catalog detail page attribution display — 61 tests passing, lint clean, build succeeds
|
||||
- One-liner:
|
||||
- One-liner:
|
||||
- Discovery landing page replacing personal dashboard — hero search trigger, popular setups feed, recent catalog items, trending categories, with auth-conditional CTA and PublicSetupCard enhanced with item counts and creator names
|
||||
- 1. [Rule 1 - Bug] Used 'house' icon instead of plan-specified 'home'
|
||||
- One-liner:
|
||||
- TopNav replaces TotalsBar across all pages, BottomTabBar wired for mobile, hero removed from landing page, and /setups added as a public route
|
||||
- Shared hobby config, popular-items-by-tags endpoint with owner count ordering, and batch onboarding completion service with auto-category creation
|
||||
- 5-step catalog-driven onboarding with hobby cards, selectable item grid, review list, and CSS step transitions following UI-SPEC design contract
|
||||
- Replaced old OnboardingWizard with new OnboardingFlow in root route, deleted old component, verified build and no stale references
|
||||
|
||||
---
|
||||
|
||||
## v2.0 Platform Foundation (Shipped: 2026-04-08)
|
||||
|
||||
**Phases completed:** 10 phases, 32 plans
|
||||
|
||||
@@ -60,19 +60,21 @@ Help people make better gear decisions — discover what others use, compare rea
|
||||
- ✓ MCP catalog tools (upsert_catalog_item, bulk_upsert_catalog) for agent seeding — v2.1
|
||||
- ✓ Discovery landing page with catalog search, popular setups feed, recent items, trending categories — v2.1
|
||||
|
||||
- ✓ Profile page with Logto-powered account management (display name, bio, avatar, email, password, delete) — v2.2
|
||||
- ✓ Image fit-within framing with dominant color background fill and crop editor — v2.2
|
||||
- ✓ Catalog-driven onboarding flow with hobby picker, category-grouped item browser, and batch collection creation — v2.2
|
||||
- ✓ Mobile icon-based action buttons on detail pages — v2.2
|
||||
|
||||
### Active
|
||||
|
||||
## Current Milestone: v2.2 User Experience Polish
|
||||
## Current Milestone: v2.3 Global & Social Ready
|
||||
|
||||
**Goal:** Fix broken user-facing features and polish the experience for real users — working profiles, better image handling, refreshed onboarding, and mobile refinements.
|
||||
**Goal:** Make GearBox work for a global audience with setup sharing, multi-currency support, and localization infrastructure.
|
||||
|
||||
**Target features:**
|
||||
- Profile page with Logto integration for account management, branded login screens, email verification
|
||||
- Image fit-within framing (letterbox/pillarbox) instead of hard crops
|
||||
- Catalog-driven onboarding flow with visual refresh
|
||||
- Mobile UX improvements (icon actions, touch refinements)
|
||||
|
||||
**Next milestone:** v2.3 Global & Social Ready — setup sharing system, multi-currency, i18n
|
||||
- Setup sharing system with visibility toggle (private/link/public)
|
||||
- Multi-currency support (USD/EUR/GBP) with user preference
|
||||
- i18n foundation with translation framework and locale-aware formatting
|
||||
|
||||
### Future
|
||||
|
||||
@@ -96,19 +98,19 @@ Help people make better gear decisions — discover what others use, compare rea
|
||||
|
||||
## Context
|
||||
|
||||
Shipped through v2.0 with 23,970 LOC TypeScript across 210+ files. All milestones v1.0-v2.0 complete.
|
||||
Shipped through v2.2 with 31 phases across 6 milestones. All milestones v1.0-v2.2 complete.
|
||||
Tech stack: React 19, Hono, Drizzle ORM, PostgreSQL, TanStack Router/Query, Tailwind CSS v4, Lucide React, Recharts, framer-motion, all on Bun.
|
||||
Primary use case is bikepacking gear but data model is hobby-agnostic.
|
||||
Auth: External OIDC via Logto (browser sessions) + API keys (programmatic) + MCP OAuth (Claude).
|
||||
Infrastructure: PostgreSQL, MinIO (S3-compatible image storage), Docker Compose for dev/prod.
|
||||
Features: MCP server (21 tools), global item catalog with attribution and bulk import, user profiles, public setup sharing, catalog-driven gear flow, item/candidate detail pages, candidate ranking/comparison/impact preview. Public discovery landing page with catalog search, popular setups feed, recent items, and trending categories.
|
||||
Features: MCP server (21 tools), global item catalog with attribution and bulk import, user profiles with Logto account management, public setup sharing, catalog-driven onboarding, fit-within image framing with crop editor, item/candidate detail pages, candidate ranking/comparison/impact preview. Public discovery landing page with catalog search, popular setups feed, recent items, and trending categories. Top nav + mobile bottom tab bar.
|
||||
20+ test files (service-level, route-level integration, MCP). E2E tests pending rewrite for OIDC auth (backlog 999.1).
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Runtime**: Bun — used as package manager and runtime
|
||||
- **Design**: Light, airy, minimalist — white/light backgrounds, lots of whitespace, no visual clutter
|
||||
- **Navigation**: Dashboard-based home page, not sidebar or top-nav tabs
|
||||
- **Navigation**: Top nav bar (desktop) + bottom tab bar (mobile), discovery landing page for unauthenticated users
|
||||
- **Auth**: External self-hosted provider — no in-house auth maintenance
|
||||
- **Database**: PostgreSQL with Drizzle ORM
|
||||
- **UGC**: Structured input only (ratings, predefined fields) — no freeform text until moderation exists
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
- ✅ **v1.3 Research & Decision Tools** — Phases 10-13 (shipped 2026-04-08)
|
||||
- ✅ **v2.0 Platform Foundation** — Phases 14-23 (shipped 2026-04-08)
|
||||
- ✅ **v2.1 Public Discovery** — Phases 24-27 (shipped 2026-04-12)
|
||||
- 🚧 **v2.2 User Experience Polish** — Phases 28-31 (in progress)
|
||||
- 📋 **v2.3 Global & Social Ready** — Phases 32-34 (planned)
|
||||
- ✅ **v2.2 User Experience Polish** — Phases 28-31 (shipped 2026-04-13)
|
||||
- 🚧 **v2.3 Global & Social Ready** — Phases 32-34 (planned)
|
||||
|
||||
## Phases
|
||||
|
||||
@@ -76,14 +76,15 @@
|
||||
|
||||
</details>
|
||||
|
||||
### v2.2 User Experience Polish (In Progress)
|
||||
<details>
|
||||
<summary>✅ v2.2 User Experience Polish (Phases 28-31) — SHIPPED 2026-04-13</summary>
|
||||
|
||||
**Milestone Goal:** Fix broken user-facing features and polish the experience for real users — working profiles, better image handling, refreshed onboarding, and mobile refinements.
|
||||
- [x] Phase 28: Profile & Logto Integration (3/3 plans) — completed 2026-04-12
|
||||
- [x] Phase 29: Image Presentation (5/5 plans) — completed 2026-04-12
|
||||
- [x] Phase 30: Onboarding Redesign (3/3 plans) — completed 2026-04-12
|
||||
- [x] Phase 31: Mobile Polish (2/2 plans) — completed 2026-04-12
|
||||
|
||||
- [x] **Phase 28: Profile & Logto Integration** — Fix profile page, integrate Logto for profile management, customize login branding, configure email verification (completed 2026-04-12)
|
||||
- [x] **Phase 29: Image Presentation** — Fit-within framing with letterbox/pillarbox instead of hard crops, optional crop positioning (completed 2026-04-12)
|
||||
- [x] **Phase 30: Onboarding Redesign** — Catalog-driven onboarding replacing manual entry, visual refresh to match current UI (promotes 999.2) (completed 2026-04-12)
|
||||
- [x] **Phase 31: Mobile Polish** — Icon-based action buttons on item views, small UX improvements (completed 2026-04-12)
|
||||
</details>
|
||||
|
||||
### v2.3 Global & Social Ready (Planned)
|
||||
|
||||
@@ -166,40 +167,6 @@ Plans:
|
||||
- [x] 27-03-PLAN.md — Root layout wiring, hero removal, and visual verification
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 28: Profile & Logto Integration
|
||||
**Goal**: Users have a working profile page with account management powered by Logto, branded login screens, and email verification
|
||||
**Depends on**: Phase 27 (v2.1 complete)
|
||||
**Requirements**: TBD (discuss phase)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
TBD (discuss phase)
|
||||
**Plans**: TBD
|
||||
|
||||
### Phase 29: Image Presentation
|
||||
**Goal**: Images display within the fixed aspect ratio using fit-within framing (letterbox/pillarbox) instead of hard crops, preserving the full image
|
||||
**Depends on**: Phase 28
|
||||
**Requirements**: TBD (discuss phase)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
TBD (discuss phase)
|
||||
**Plans**: TBD
|
||||
|
||||
### Phase 30: Onboarding Redesign
|
||||
**Goal**: New users experience a polished, catalog-driven onboarding flow that matches the current UI style and guides them through their first setup
|
||||
**Depends on**: Phase 28
|
||||
**Requirements**: TBD (discuss phase)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
TBD (discuss phase)
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 31: Mobile Polish
|
||||
**Goal**: Mobile item views use icon-based action buttons instead of text labels, with small UX refinements across touch interactions
|
||||
**Depends on**: None
|
||||
**Requirements**: TBD (discuss phase)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
TBD (discuss phase)
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 32: Setup Sharing System
|
||||
**Goal**: Setup owners can toggle visibility between private, link-shared, and public, with schema designed for future likes, friends, and collaborative editing
|
||||
**Depends on**: Phase 28 (profiles working)
|
||||
@@ -257,7 +224,7 @@ Plans:
|
||||
| 26. Discovery Landing Page | v2.1 | 3/3 | Complete | 2026-04-10 |
|
||||
| 27. Top Nav Restructure & Search Bar Rethink | v2.1 | 4/4 | Complete | 2026-04-12 |
|
||||
| 28. Profile & Logto Integration | v2.2 | 3/3 | Complete | 2026-04-12 |
|
||||
| 29. Image Presentation | v2.2 | 4/4 | Complete | 2026-04-12 |
|
||||
| 29. Image Presentation | v2.2 | 5/5 | Complete | 2026-04-13 |
|
||||
| 30. Onboarding Redesign | v2.2 | 3/3 | Complete | 2026-04-12 |
|
||||
| 31. Mobile Polish | v2.2 | 2/2 | Complete | 2026-04-12 |
|
||||
| 32. Setup Sharing System | v2.3 | TBD | Pending | — |
|
||||
|
||||
@@ -4,13 +4,13 @@ milestone: v2.2
|
||||
milestone_name: User Experience Polish
|
||||
status: executing
|
||||
stopped_at: Phase 31 context gathered
|
||||
last_updated: "2026-04-12T18:50:04.872Z"
|
||||
last_activity: 2026-04-12
|
||||
last_updated: "2026-04-13T13:55:33.612Z"
|
||||
last_activity: 2026-04-13
|
||||
progress:
|
||||
total_phases: 36
|
||||
completed_phases: 24
|
||||
total_plans: 67
|
||||
completed_plans: 65
|
||||
total_plans: 68
|
||||
completed_plans: 66
|
||||
percent: 97
|
||||
---
|
||||
|
||||
@@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-04-09)
|
||||
Phase: 31
|
||||
Plan: Not started
|
||||
Status: Executing Phase 30
|
||||
Last activity: 2026-04-12
|
||||
Last activity: 2026-04-13
|
||||
|
||||
Progress: [░░░░░░░░░░] 0%
|
||||
|
||||
@@ -80,7 +80,7 @@ v2.1 decisions:
|
||||
|
||||
### Pending Todos
|
||||
|
||||
None active.
|
||||
- Fix Add Candidate button shows wrong modal on thread page (ui)
|
||||
|
||||
### Blockers/Concerns
|
||||
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"plan_check": true,
|
||||
"verifier": true,
|
||||
"nyquist_validation": true,
|
||||
"_auto_chain_active": true
|
||||
"_auto_chain_active": false
|
||||
}
|
||||
}
|
||||
55
.planning/debug/crop-preview-edit-state.md
Normal file
55
.planning/debug/crop-preview-edit-state.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
status: diagnosed
|
||||
trigger: "crop editor opens on upload correctly, but after cropping the cropped image isn't shown in the edit state always — after clicking save it is shown correctly"
|
||||
created: 2026-04-13T12:30:00Z
|
||||
updated: 2026-04-13T12:35:00Z
|
||||
---
|
||||
|
||||
## Current Focus
|
||||
|
||||
hypothesis: GearImage in ImageUpload receives no crop props after cropping — crop values are sent to server via onCropChange but never stored locally or passed to the preview GearImage
|
||||
test: trace data flow from ImageCropEditor.onSave through ImageUpload to GearImage rendering
|
||||
expecting: GearImage in ImageUpload has no cropZoom/cropX/cropY props
|
||||
next_action: return diagnosis
|
||||
|
||||
## Symptoms
|
||||
|
||||
expected: After cropping in the crop editor, the image preview in edit mode should immediately reflect the crop
|
||||
actual: Cropped image not shown in edit state after cropping; shows correctly only after Save
|
||||
errors: None
|
||||
reproduction: Upload image to item -> crop editor opens -> adjust crop -> close editor -> preview shows uncropped image -> Save item -> page re-renders with crop applied
|
||||
started: Since Phase 29 implementation
|
||||
|
||||
## Eliminated
|
||||
|
||||
(none needed — root cause found on first hypothesis)
|
||||
|
||||
## Evidence
|
||||
|
||||
- timestamp: 2026-04-13T12:32:00Z
|
||||
checked: ImageUpload.tsx lines 83-95 — ImageCropEditor onSave handler
|
||||
found: onSave calls onCropChange(result) then setShowCropEditor(false). The crop values are passed up to the parent but NOT stored in any local state within ImageUpload.
|
||||
implication: After crop editor closes, ImageUpload has no memory of what crop was applied.
|
||||
|
||||
- timestamp: 2026-04-13T12:33:00Z
|
||||
checked: ImageUpload.tsx lines 109-114 — GearImage rendering after crop editor closes
|
||||
found: GearImage is rendered with only src, alt, and dominantColor props. NO cropZoom, cropX, or cropY props are passed. The component never receives crop values.
|
||||
implication: GearImage renders uncropped because it literally has no crop data to apply.
|
||||
|
||||
- timestamp: 2026-04-13T12:34:00Z
|
||||
checked: $itemId.tsx lines 277-294 — onCropChange callback in item detail page
|
||||
found: onCropChange triggers updateItem.mutate() which sends crop values to the server immediately. This is a fire-and-forget mutation — it does NOT update local state or the React Query cache synchronously.
|
||||
implication: Crop values reach the server, but the local component tree has no access to them until the query is invalidated/refetched.
|
||||
|
||||
- timestamp: 2026-04-13T12:34:30Z
|
||||
checked: $itemId.tsx lines 326-335 — GearImage in non-edit view mode
|
||||
found: Non-edit view reads cropZoom, cropX, cropY from item (React Query cache data). After Save, the mutation invalidates the query, item refetches with crop values, and GearImage renders correctly.
|
||||
implication: Confirms the "works after save" behavior — the query refetch provides the crop data.
|
||||
|
||||
## Resolution
|
||||
|
||||
root_cause: ImageUpload component does not track crop values locally after the crop editor closes. When the crop editor's onSave fires, the crop values are forwarded to the parent ($itemId.tsx) which sends them to the server via updateItem.mutate(), but no local state is updated. The GearImage rendered inside ImageUpload receives zero crop-related props (cropZoom, cropX, cropY are never passed). So the preview always shows the uncropped/default image. After the user clicks Save on the item form, the React Query cache is invalidated, the item refetches with server-side crop values, and the page re-renders in view mode with the correct crop applied.
|
||||
|
||||
fix: (not applied — diagnosis only)
|
||||
verification: (not applied — diagnosis only)
|
||||
files_changed: []
|
||||
156
.planning/milestones/v2.2-REQUIREMENTS.md
Normal file
156
.planning/milestones/v2.2-REQUIREMENTS.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Requirements Archive: v2.2 User Experience Polish
|
||||
|
||||
**Archived:** 2026-04-13
|
||||
**Status:** SHIPPED
|
||||
|
||||
For current requirements, see `.planning/REQUIREMENTS.md`.
|
||||
|
||||
---
|
||||
|
||||
# Requirements: GearBox v2.1 Public Discovery
|
||||
|
||||
**Defined:** 2026-04-09
|
||||
**Core Value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
|
||||
|
||||
## v2.1 Requirements
|
||||
|
||||
Requirements for Public Discovery milestone. Each maps to roadmap phases.
|
||||
|
||||
### Public Access
|
||||
|
||||
- [x] **PUBL-01**: User can browse the global item catalog without logging in
|
||||
- [x] **PUBL-02**: User can view public setups without logging in
|
||||
- [x] **PUBL-03**: User can view user profiles without logging in
|
||||
- [x] **PUBL-04**: Anonymous visitors see the landing page without auth spinner or redirect
|
||||
- [x] **PUBL-05**: Login is only required when user attempts to create/edit/delete their own data
|
||||
|
||||
### Discovery
|
||||
|
||||
- [x] **DISC-01**: Landing page displays an always-visible catalog search bar at the top
|
||||
- [x] **DISC-02**: Landing page shows a feed of popular setups below the search
|
||||
- [x] **DISC-03**: Landing page shows recently added catalog items
|
||||
- [x] **DISC-04**: Landing page shows trending categories
|
||||
- [x] **DISC-05**: Authenticated users see a "Go to Collection" entry point from the landing page
|
||||
|
||||
### Catalog Enrichment
|
||||
|
||||
- [x] **CATL-01**: Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl)
|
||||
- [x] **CATL-02**: Global items have a unique constraint on (brand, model) preventing duplicates
|
||||
- [x] **CATL-03**: Catalog detail pages display image attribution with credit and source link
|
||||
- [x] **CATL-04**: Bulk import API endpoint accepts multiple catalog items in one request
|
||||
- [x] **CATL-05**: Bulk import uses upsert semantics (ON CONFLICT update, not fail)
|
||||
|
||||
### Agent Seeding Tools
|
||||
|
||||
- [x] **SEED-01**: MCP server has a dedicated `upsert_catalog_item` tool that writes to globalItems (not user-scoped)
|
||||
- [x] **SEED-02**: MCP server has a `bulk_upsert_catalog` tool for batch catalog population
|
||||
- [x] **SEED-03**: Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- [x] **INFR-01**: Public API endpoints are rate-limited to prevent abuse
|
||||
- [x] **INFR-02**: Discovery feed endpoint uses cursor pagination for scalability
|
||||
|
||||
## Future Requirements
|
||||
|
||||
Deferred to future milestones. Tracked but not in current roadmap.
|
||||
|
||||
### Personalization
|
||||
|
||||
- **PERS-01**: Logged-in users see a feed tuned to their collection categories
|
||||
- **PERS-02**: Feed algorithm recommends content based on user's hobby interests
|
||||
|
||||
### Reviews & Content
|
||||
|
||||
- **REVW-01**: Users can write structured reviews on catalog items
|
||||
- **REVW-02**: Reviews appear in the discovery feed
|
||||
- **REVW-03**: Curated/linked external reviews surface in feed
|
||||
|
||||
### SEO
|
||||
|
||||
- **SEO-01**: Catalog pages are crawlable by search engine bots
|
||||
- **SEO-02**: Catalog pages have proper meta tags and structured data
|
||||
|
||||
### Catalog Seeding
|
||||
|
||||
- **SEED-04**: Initial seeding run populates 100+ items across key categories via agent swarm
|
||||
|
||||
### Reviews & Ratings (from v2.0)
|
||||
|
||||
- **REV-01**: User can rate a global item with an overall star rating
|
||||
- **REV-02**: User can rate a global item on predefined dimensions (durability, value, etc.)
|
||||
- **REV-03**: Item detail pages show average ratings from all reviewers
|
||||
|
||||
### Aggregation (from v2.0)
|
||||
|
||||
- **AGG-01**: Item detail pages show crowd-verified specs (manufacturer vs community-measured weight)
|
||||
- **AGG-02**: Item detail pages show which setups include this item
|
||||
- **AGG-03**: Setup composition insights ("commonly paired with")
|
||||
|
||||
### Social (from v2.0)
|
||||
|
||||
- **SOCL-01**: User can fork/copy a public setup as a template
|
||||
- **SOCL-02**: Planning thread candidates can link to global items for auto-populated specs
|
||||
- **SOCL-03**: User can follow other users
|
||||
- **SOCL-04**: User can view an activity feed of followed users' content
|
||||
|
||||
### Content Moderation (from v2.0)
|
||||
|
||||
- **MOD-01**: User can submit freeform text reviews
|
||||
- **MOD-02**: User can report inappropriate content
|
||||
- **MOD-03**: Admin can review and act on reported content
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Explicitly excluded. Documented to prevent scope creep.
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| Personalized feed algorithm | Requires usage data and collection analysis — build simple feed first |
|
||||
| SSR / static prerendering for SEO | Needs dedicated research on approach for TanStack Router — defer to v2.2+ |
|
||||
| Freeform reviews / comments | No moderation infrastructure yet — structured UGC only |
|
||||
| Admin role / permission system | Current auth model has no role distinction — API key sufficient for v2.1 |
|
||||
| Image scraping automation | Legal gray area — agent seeding uses manufacturer-provided images with attribution |
|
||||
| User-to-user messaging | High moderation burden, not core to discovery |
|
||||
| Wiki-style open item editing | Quality control risk; structured contributions only |
|
||||
| Marketplace / buy-sell | Payment processing, fraud, legal liability |
|
||||
| AI gear recommendations | Training data requirements, hallucination risk |
|
||||
| Gamification (badges, points) | Incentivizes quantity over quality |
|
||||
| Price tracking / deal alerts | Requires scraping, fragile, legal gray area |
|
||||
| Mobile native app | Web-first, responsive design sufficient |
|
||||
|
||||
## Traceability
|
||||
|
||||
Which phases cover which requirements. Updated during roadmap creation.
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| PUBL-01 | Phase 24 | Complete |
|
||||
| PUBL-02 | Phase 24 | Complete |
|
||||
| PUBL-03 | Phase 24 | Complete |
|
||||
| PUBL-04 | Phase 24 | Complete |
|
||||
| PUBL-05 | Phase 24 | Complete |
|
||||
| INFR-01 | Phase 24 | Complete |
|
||||
| CATL-01 | Phase 25 | Complete |
|
||||
| CATL-02 | Phase 25 | Complete |
|
||||
| CATL-03 | Phase 25 | Complete |
|
||||
| CATL-04 | Phase 25 | Complete |
|
||||
| CATL-05 | Phase 25 | Complete |
|
||||
| SEED-01 | Phase 25 | Complete |
|
||||
| SEED-02 | Phase 25 | Complete |
|
||||
| SEED-03 | Phase 25 | Complete |
|
||||
| DISC-01 | Phase 26 | Complete |
|
||||
| DISC-02 | Phase 26 | Complete |
|
||||
| DISC-03 | Phase 26 | Complete |
|
||||
| DISC-04 | Phase 26 | Complete |
|
||||
| DISC-05 | Phase 26 | Complete |
|
||||
| INFR-02 | Phase 26 | Complete |
|
||||
|
||||
**Coverage:**
|
||||
- v2.1 requirements: 20 total
|
||||
- Mapped to phases: 20
|
||||
- Unmapped: 0
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-04-09*
|
||||
*Last updated: 2026-04-09 after roadmap creation*
|
||||
340
.planning/milestones/v2.2-ROADMAP.md
Normal file
340
.planning/milestones/v2.2-ROADMAP.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Roadmap: GearBox
|
||||
|
||||
## Milestones
|
||||
|
||||
- ✅ **v1.0 MVP** — Phases 1-3 (shipped 2026-03-15)
|
||||
- ✅ **v1.1 Fixes & Polish** — Phases 4-6 (shipped 2026-03-15)
|
||||
- ✅ **v1.2 Collection Power-Ups** — Phases 7-9 (shipped 2026-03-16)
|
||||
- ✅ **v1.3 Research & Decision Tools** — Phases 10-13 (shipped 2026-04-08)
|
||||
- ✅ **v2.0 Platform Foundation** — Phases 14-23 (shipped 2026-04-08)
|
||||
- ✅ **v2.1 Public Discovery** — Phases 24-27 (shipped 2026-04-12)
|
||||
- 🚧 **v2.2 User Experience Polish** — Phases 28-31 (in progress)
|
||||
- 📋 **v2.3 Global & Social Ready** — Phases 32-34 (planned)
|
||||
|
||||
## Phases
|
||||
|
||||
<details>
|
||||
<summary>✅ v1.0 MVP (Phases 1-3) — SHIPPED 2026-03-15</summary>
|
||||
|
||||
- [x] Phase 1: Foundation and Collection (4/4 plans) — completed 2026-03-14
|
||||
- [x] Phase 2: Planning Threads (3/3 plans) — completed 2026-03-15
|
||||
- [x] Phase 3: Setups and Dashboard (3/3 plans) — completed 2026-03-15
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>✅ v1.1 Fixes & Polish (Phases 4-6) — SHIPPED 2026-03-15</summary>
|
||||
|
||||
- [x] Phase 4: Database & Planning Fixes (2/2 plans) — completed 2026-03-15
|
||||
- [x] Phase 5: Image Handling (2/2 plans) — completed 2026-03-15
|
||||
- [x] Phase 6: Category Icons (3/3 plans) — completed 2026-03-15
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>✅ v1.2 Collection Power-Ups (Phases 7-9) — SHIPPED 2026-03-16</summary>
|
||||
|
||||
- [x] Phase 7: Weight Unit Selection (2/2 plans) — completed 2026-03-16
|
||||
- [x] Phase 8: Search, Filter, and Candidate Status (2/2 plans) — completed 2026-03-16
|
||||
- [x] Phase 9: Weight Classification and Visualization (2/2 plans) — completed 2026-03-16
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>✅ v1.3 Research & Decision Tools (Phases 10-13) — SHIPPED 2026-04-08</summary>
|
||||
|
||||
- [x] Phase 10: Schema Foundation + Pros/Cons Fields (1/1 plans) — completed 2026-03-16
|
||||
- [x] Phase 11: Candidate Ranking (2/2 plans) — completed 2026-03-16
|
||||
- [x] Phase 12: Comparison View (1/1 plans) — completed 2026-03-17
|
||||
- [x] Phase 13: Setup Impact Preview (2/2 plans) — completed 2026-04-08
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>✅ v2.0 Platform Foundation (Phases 14-23) — SHIPPED 2026-04-08</summary>
|
||||
|
||||
- [x] Phase 14: PostgreSQL Migration (6/6 plans) — completed 2026-04-05
|
||||
- [x] Phase 15: External Authentication (3/3 plans) — completed 2026-04-05
|
||||
- [x] Phase 16: Multi-User Data Model (4/4 plans) — completed 2026-04-05
|
||||
- [x] Phase 17: Object Storage (3/3 plans) — completed 2026-04-05
|
||||
- [x] Phase 18: Global Items & Public Profiles (5/5 plans) — completed 2026-04-05
|
||||
- [x] Phase 19: Reference Item Model & Tags Schema (3/3 plans) — completed 2026-04-05
|
||||
- [x] Phase 20: FAB & Full-Screen Catalog Search (2/2 plans) — completed 2026-04-06
|
||||
- [x] Phase 21: Item & Catalog Detail Pages (3/3 plans) — completed 2026-04-06
|
||||
- [x] Phase 22: Add-from-Catalog & Thread Integration (2/2 plans) — completed 2026-04-06
|
||||
- [x] Phase 23: Manual Entry Fallback (1/1 plans) — completed 2026-04-06
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>✅ v2.1 Public Discovery (Phases 24-27) — SHIPPED 2026-04-12</summary>
|
||||
|
||||
- [x] Phase 24: Public Access & Infrastructure (2/2 plans) — completed 2026-04-10
|
||||
- [x] Phase 25: Catalog Enrichment & Agent Tools (2/2 plans) — completed 2026-04-10
|
||||
- [x] Phase 26: Discovery Landing Page (3/3 plans) — completed 2026-04-10
|
||||
- [x] Phase 27: Top Nav Restructure & Search Bar Rethink (4/4 plans) — completed 2026-04-12
|
||||
|
||||
</details>
|
||||
|
||||
### v2.2 User Experience Polish (In Progress)
|
||||
|
||||
**Milestone Goal:** Fix broken user-facing features and polish the experience for real users — working profiles, better image handling, refreshed onboarding, and mobile refinements.
|
||||
|
||||
- [x] **Phase 28: Profile & Logto Integration** — Fix profile page, integrate Logto for profile management, customize login branding, configure email verification (completed 2026-04-12)
|
||||
- [x] **Phase 29: Image Presentation** — Fit-within framing with letterbox/pillarbox instead of hard crops, optional crop positioning (completed 2026-04-12)
|
||||
- [x] **Phase 30: Onboarding Redesign** — Catalog-driven onboarding replacing manual entry, visual refresh to match current UI (promotes 999.2) (completed 2026-04-12)
|
||||
- [x] **Phase 31: Mobile Polish** — Icon-based action buttons on item views, small UX improvements (completed 2026-04-12)
|
||||
|
||||
### v2.3 Global & Social Ready (Planned)
|
||||
|
||||
**Milestone Goal:** Make GearBox work for a global audience with setup sharing, multi-currency support, and localization infrastructure.
|
||||
|
||||
- [ ] **Phase 32: Setup Sharing System** — Visibility toggle (private/link/public), link sharing, schema future-proofed for likes, friends, and collaborative editing
|
||||
- [ ] **Phase 33: Currency System** — Multi-currency support (USD/EUR/GBP), price display per user preference
|
||||
- [ ] **Phase 34: i18n Foundation** — Translation framework, string extraction, locale-aware formatting
|
||||
|
||||
## Phase Details
|
||||
|
||||
### Phase 24: Public Access & Infrastructure
|
||||
**Goal**: Anyone can browse the catalog, public setups, and user profiles without logging in
|
||||
**Depends on**: Phase 23 (v2.0 complete)
|
||||
**Requirements**: PUBL-01, PUBL-02, PUBL-03, PUBL-04, PUBL-05, INFR-01
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Visiting the app without a session shows the app content immediately — no auth spinner, no redirect to login
|
||||
2. An unauthenticated visitor can browse the global item catalog and open a catalog detail page
|
||||
3. An unauthenticated visitor can view a public setup and see its items and totals
|
||||
4. An unauthenticated visitor can view a user's public profile page
|
||||
5. Attempting to create, edit, or delete any item/setup/thread while unauthenticated redirects to login
|
||||
**Plans**: 2 plans
|
||||
|
||||
Plans:
|
||||
- [x] 24-01-PLAN.md — Rate limit factory and tiered public endpoint protection
|
||||
- [x] 24-02-PLAN.md — Client-side public access (render-first root, auth prompt, setup/catalog guards)
|
||||
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 25: Catalog Enrichment & Agent Tools
|
||||
**Goal**: Global items carry attribution metadata and can be bulk-populated by an MCP agent swarm
|
||||
**Depends on**: Phase 24
|
||||
**Requirements**: CATL-01, CATL-02, CATL-03, CATL-04, CATL-05, SEED-01, SEED-02, SEED-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. A catalog item detail page displays image credit and a link to the image source
|
||||
2. Attempting to import two items with the same brand and model updates the existing record rather than creating a duplicate
|
||||
3. A single API call with an array of items imports them all, upserting on (brand, model) conflict
|
||||
4. An MCP agent can call `upsert_catalog_item` with attribution fields and the item appears in the catalog
|
||||
5. An MCP agent can call `bulk_upsert_catalog` with a batch of items and all are persisted with attribution
|
||||
**Plans**: 2 plans
|
||||
|
||||
Plans:
|
||||
- [x] 25-01-PLAN.md — Schema migration (attribution columns + unique constraint) and upsert service layer
|
||||
- [ ] 25-02-PLAN.md — HTTP upsert routes, MCP catalog tools, and client attribution display
|
||||
|
||||
### Phase 26: Discovery Landing Page
|
||||
**Goal**: The app opens to a public discovery feed with prominent catalog search, not a personal dashboard
|
||||
**Depends on**: Phase 25
|
||||
**Requirements**: DISC-01, DISC-02, DISC-03, DISC-04, DISC-05, INFR-02
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. The root URL shows a landing page with a catalog search bar at the top, visible without logging in
|
||||
2. Below the search bar, a feed of popular public setups is visible with titles, creator names, and item counts
|
||||
3. The landing page shows a section of recently added catalog items
|
||||
4. The landing page shows a section of trending categories
|
||||
5. A logged-in user sees a "Go to Collection" link or button on the landing page that navigates to their personal collection
|
||||
**Plans**: 3 plans
|
||||
|
||||
Plans:
|
||||
- [x] 26-01-PLAN.md — Discovery service layer with cursor pagination (TDD)
|
||||
- [x] 26-02-PLAN.md — Discovery routes, server registration, and client hooks
|
||||
- [x] 26-03-PLAN.md — Landing page UI and PublicSetupCard enhancement
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 27: Top Nav Restructure & Search Bar Rethink
|
||||
**Goal**: Replace the minimal TotalsBar with a persistent top navigation bar (logo, section links, catalog search, user avatar) and move mobile navigation to a bottom tab bar — elevating Setups to top-level and removing the landing page hero
|
||||
**Depends on**: Phase 26
|
||||
**Requirements**: NAV-01, NAV-02, NAV-03, NAV-04, NAV-05
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. A persistent top nav bar shows logo, Home/Collection/Setups links, catalog search, and user avatar on desktop
|
||||
2. Clicking Collection or Setups while anonymous triggers AuthPromptModal instead of navigating
|
||||
3. On mobile, navigation appears as a fixed bottom tab bar with Home, Collection, Setups, and Search icons
|
||||
4. The landing page no longer has a hero section — content starts with Popular Setups
|
||||
5. Setups has its own top-level route accessible from the nav bar, not nested in Collection tabs
|
||||
**Plans**: 4 plans
|
||||
|
||||
Plans:
|
||||
- [x] 27-00-PLAN.md — Wave 0: E2E test scaffolding for nav restructure
|
||||
- [x] 27-01-PLAN.md — TopNav and BottomTabBar components
|
||||
- [x] 27-02-PLAN.md — Setups top-level route and Collection tab simplification
|
||||
- [x] 27-03-PLAN.md — Root layout wiring, hero removal, and visual verification
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 28: Profile & Logto Integration
|
||||
**Goal**: Users have a working profile page with account management powered by Logto, branded login screens, and email verification
|
||||
**Depends on**: Phase 27 (v2.1 complete)
|
||||
**Requirements**: TBD (discuss phase)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
TBD (discuss phase)
|
||||
**Plans**: TBD
|
||||
|
||||
### Phase 29: Image Presentation
|
||||
**Goal**: Images display within the fixed aspect ratio using fit-within framing (letterbox/pillarbox) instead of hard crops, preserving the full image
|
||||
**Depends on**: Phase 28
|
||||
**Requirements**: TBD (discuss phase)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
TBD (discuss phase)
|
||||
**Plans**: TBD
|
||||
|
||||
### Phase 30: Onboarding Redesign
|
||||
**Goal**: New users experience a polished, catalog-driven onboarding flow that matches the current UI style and guides them through their first setup
|
||||
**Depends on**: Phase 28
|
||||
**Requirements**: TBD (discuss phase)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
TBD (discuss phase)
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 31: Mobile Polish
|
||||
**Goal**: Mobile item views use icon-based action buttons instead of text labels, with small UX refinements across touch interactions
|
||||
**Depends on**: None
|
||||
**Requirements**: TBD (discuss phase)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
TBD (discuss phase)
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 32: Setup Sharing System
|
||||
**Goal**: Setup owners can toggle visibility between private, link-shared, and public, with schema designed for future likes, friends, and collaborative editing
|
||||
**Depends on**: Phase 28 (profiles working)
|
||||
**Requirements**: TBD (discuss phase)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
TBD (discuss phase)
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 33: Currency System
|
||||
**Goal**: Users can select their preferred currency (USD/EUR/GBP) and all prices display accordingly
|
||||
**Depends on**: Phase 32
|
||||
**Requirements**: TBD (discuss phase)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
TBD (discuss phase)
|
||||
**Plans**: TBD
|
||||
|
||||
### Phase 34: i18n Foundation
|
||||
**Goal**: Translation framework in place with string extraction, locale-aware formatting, and at least English + one additional language
|
||||
**Depends on**: Phase 33
|
||||
**Requirements**: TBD (discuss phase)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
TBD (discuss phase)
|
||||
**Plans**: TBD
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||
|-------|-----------|----------------|--------|-----------|
|
||||
| 1. Foundation and Collection | v1.0 | 4/4 | Complete | 2026-03-14 |
|
||||
| 2. Planning Threads | v1.0 | 3/3 | Complete | 2026-03-15 |
|
||||
| 3. Setups and Dashboard | v1.0 | 3/3 | Complete | 2026-03-15 |
|
||||
| 4. Database & Planning Fixes | v1.1 | 2/2 | Complete | 2026-03-15 |
|
||||
| 5. Image Handling | v1.1 | 2/2 | Complete | 2026-03-15 |
|
||||
| 6. Category Icons | v1.1 | 3/3 | Complete | 2026-03-15 |
|
||||
| 7. Weight Unit Selection | v1.2 | 2/2 | Complete | 2026-03-16 |
|
||||
| 8. Search, Filter, and Candidate Status | v1.2 | 2/2 | Complete | 2026-03-16 |
|
||||
| 9. Weight Classification and Visualization | v1.2 | 2/2 | Complete | 2026-03-16 |
|
||||
| 10. Schema Foundation + Pros/Cons Fields | v1.3 | 1/1 | Complete | 2026-03-16 |
|
||||
| 11. Candidate Ranking | v1.3 | 2/2 | Complete | 2026-03-16 |
|
||||
| 12. Comparison View | v1.3 | 1/1 | Complete | 2026-03-17 |
|
||||
| 13. Setup Impact Preview | v1.3 | 2/2 | Complete | 2026-04-08 |
|
||||
| 14. PostgreSQL Migration | v2.0 | 6/6 | Complete | 2026-04-05 |
|
||||
| 15. External Authentication | v2.0 | 3/3 | Complete | 2026-04-05 |
|
||||
| 16. Multi-User Data Model | v2.0 | 4/4 | Complete | 2026-04-05 |
|
||||
| 17. Object Storage | v2.0 | 3/3 | Complete | 2026-04-05 |
|
||||
| 18. Global Items & Public Profiles | v2.0 | 5/5 | Complete | 2026-04-05 |
|
||||
| 19. Reference Item Model & Tags Schema | v2.0 | 3/3 | Complete | 2026-04-05 |
|
||||
| 20. FAB & Full-Screen Catalog Search | v2.0 | 2/2 | Complete | 2026-04-06 |
|
||||
| 21. Item & Catalog Detail Pages | v2.0 | 3/3 | Complete | 2026-04-06 |
|
||||
| 22. Add-from-Catalog & Thread Integration | v2.0 | 2/2 | Complete | 2026-04-06 |
|
||||
| 23. Manual Entry Fallback | v2.0 | 1/1 | Complete | 2026-04-06 |
|
||||
| 24. Public Access & Infrastructure | v2.1 | 2/2 | Complete | 2026-04-10 |
|
||||
| 25. Catalog Enrichment & Agent Tools | v2.1 | 2/2 | Complete | 2026-04-10 |
|
||||
| 26. Discovery Landing Page | v2.1 | 3/3 | Complete | 2026-04-10 |
|
||||
| 27. Top Nav Restructure & Search Bar Rethink | v2.1 | 4/4 | Complete | 2026-04-12 |
|
||||
| 28. Profile & Logto Integration | v2.2 | 3/3 | Complete | 2026-04-12 |
|
||||
| 29. Image Presentation | v2.2 | 5/5 | Complete | 2026-04-13 |
|
||||
| 30. Onboarding Redesign | v2.2 | 3/3 | Complete | 2026-04-12 |
|
||||
| 31. Mobile Polish | v2.2 | 2/2 | Complete | 2026-04-12 |
|
||||
| 32. Setup Sharing System | v2.3 | TBD | Pending | — |
|
||||
| 33. Currency System | v2.3 | TBD | Pending | — |
|
||||
| 34. i18n Foundation | v2.3 | TBD | Pending | — |
|
||||
|
||||
## Backlog
|
||||
|
||||
### Phase 999.1: Rewrite E2E Tests for OIDC Auth (BACKLOG)
|
||||
**Goal**: E2E tests currently expect local username/password login but auth moved to external OIDC (Logto). Rewrite with mock OIDC provider or API-key-based auth bypass. Seed migration to Postgres is already done.
|
||||
**Requirements**: TBD
|
||||
**Plans**: TBD
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||
|
||||
### Phase 999.2: Revamp Onboarding Flow (BACKLOG)
|
||||
**Goal**: Redesign the onboarding experience to match the current app style and flow. Replace the manual item edit form with the catalog search function. Visual refresh to align with the newer UI patterns.
|
||||
**Status**: Promoted to Phase 30 (v2.2)
|
||||
**Requirements**: TBD
|
||||
**Plans**: TBD
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||
|
||||
### Phase 999.5: Legal Pages — ToS, Privacy Policy, and Compliance (BACKLOG)
|
||||
**Goal**: Create Terms of Service, Privacy Policy, and any other required legal/compliance pages for a public-facing platform. Essential before opening to real users.
|
||||
**Requirements**: TBD
|
||||
**Plans**: TBD
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||
|
||||
### Phase 999.6: Admin Panel (BACKLOG)
|
||||
**Goal**: Build an admin panel for reviewing user-submitted items (catalog submissions), managing global/reference items, and general platform administration. Includes approval workflows for community contributions.
|
||||
**Requirements**: TBD
|
||||
**Plans**: TBD
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||
|
||||
### Phase 999.7: User Feedback System (BACKLOG)
|
||||
**Goal**: Add an in-app feedback collection mechanism so users can report bugs, suggest features, and share general feedback. Could be a simple form, widget, or integration with an external tool.
|
||||
**Requirements**: TBD
|
||||
**Plans**: TBD
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||
|
||||
### Phase 999.8: Analytics Integration (BACKLOG)
|
||||
**Goal**: Integrate privacy-respecting analytics (PostHog, Umami, or similar) to understand usage patterns, popular categories, search behavior, and feature adoption. Self-hosted preferred to align with independent ethos.
|
||||
**Requirements**: TBD
|
||||
**Plans**: TBD
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||
|
||||
### Phase 999.9: Mobile App (BACKLOG)
|
||||
**Goal**: Bring GearBox to mobile. Start with a PWA for quick wins (offline support, home screen install), then evaluate dedicated native apps (React Native / Flutter) for richer experience — camera for weight verification, barcode scanning, etc.
|
||||
**Requirements**: TBD
|
||||
**Plans**: TBD
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||
|
||||
### Phase 999.10: Monetization Strategy (BACKLOG)
|
||||
**Goal**: Define how GearBox sustains itself financially. Options to explore: sponsored/promoted items (brand X promotes product Y), premium features, affiliate links. Critical tension: revenue vs. independent credibility — GearBox's value is unbiased gear data, so monetization must not compromise trust. Needs deep discussion before implementation.
|
||||
**Requirements**: TBD
|
||||
**Plans**: TBD
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||
|
||||
### Phase 999.11: Marketing Website (BACKLOG)
|
||||
**Goal**: Build a separate marketing/brand website (www.gearbox.de) distinct from the app (app.gearbox.de). Hero section with search bar, value proposition, feature highlights, how-it-works, social proof, and sign-up CTA. This is the public-facing front door — the first thing people see before they enter the app. The current discovery page is the in-app experience; this is the standalone website around it.
|
||||
**Requirements**: TBD
|
||||
**Plans**: TBD
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
status: complete
|
||||
phase: 28-profile-and-logto-integration
|
||||
source: [28-01-SUMMARY.md, 28-02-SUMMARY.md, 28-03-SUMMARY.md]
|
||||
started: 2026-04-12T18:30:00Z
|
||||
updated: 2026-04-12T21:00:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Profile page navigation
|
||||
expected: Click your avatar in the top nav. The dropdown shows "Profile" above "Settings". Clicking it navigates to /profile.
|
||||
result: pass
|
||||
|
||||
### 2. Profile page sections
|
||||
expected: /profile page shows four sections: Profile Info (displayName, bio, avatar), Account Info (email, member-since date), Security (password change), and Danger Zone (delete account).
|
||||
result: pass
|
||||
|
||||
### 3. Settings page separation
|
||||
expected: /settings page shows only app preferences: weight unit, currency, import/export, API keys. No profile section.
|
||||
result: pass
|
||||
|
||||
### 4. Edit display name, bio, and avatar
|
||||
expected: On /profile, upload an avatar, change display name and bio, click Save. Avatar image renders. Refreshing shows updated values.
|
||||
result: pass
|
||||
reported: "Fixed: avatar now uses presigned S3 URLs instead of /uploads/ paths. Avatar also shows in top nav."
|
||||
|
||||
### 5. Email display
|
||||
expected: Account Info section shows your email address (from Logto) and a "Change" button next to it.
|
||||
result: pass
|
||||
reported: "Fixed: M2M credentials configured, email change now reflects in UI immediately via optimistic cache update."
|
||||
|
||||
### 6. Password change form
|
||||
expected: Security section shows a password change form. Current password, new password, confirm new password fields.
|
||||
result: pass
|
||||
|
||||
### 7. Delete account UI
|
||||
expected: Danger Zone shows a red-bordered card with "Delete Account" button. Clicking it shows a confirmation dialog requiring you to type "DELETE" before proceeding.
|
||||
result: pass
|
||||
|
||||
### 8. Member-since date
|
||||
expected: Account Info section shows a "Member since" date formatted nicely (e.g., "April 2026").
|
||||
result: pass
|
||||
|
||||
## Summary
|
||||
|
||||
total: 8
|
||||
passed: 8
|
||||
issues: 0
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
[none]
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
phase: 29-image-presentation
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/client/components/ImageUpload.tsx
|
||||
autonomous: true
|
||||
gap_closure: true
|
||||
requirements: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "After cropping in the upload crop editor, the GearImage preview immediately reflects the crop values without needing to save the form"
|
||||
artifacts:
|
||||
- path: "src/client/components/ImageUpload.tsx"
|
||||
provides: "Local crop state that feeds GearImage preview"
|
||||
contains: "cropZoom"
|
||||
key_links:
|
||||
- from: "ImageCropEditor onSave"
|
||||
to: "GearImage cropZoom/cropX/cropY props"
|
||||
via: "local state in ImageUpload"
|
||||
pattern: "localCrop"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Fix cropped image preview not updating immediately after cropping in edit mode.
|
||||
|
||||
Purpose: When a user crops an image via the ImageCropEditor inside ImageUpload, the preview should reflect the crop immediately — not only after form save and query refetch.
|
||||
|
||||
Output: ImageUpload component with local crop state that feeds into GearImage preview props.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@src/client/components/ImageUpload.tsx
|
||||
@src/client/components/GearImage.tsx
|
||||
@src/client/components/ImageCropEditor.tsx
|
||||
|
||||
<interfaces>
|
||||
<!-- GearImage accepts optional crop props -->
|
||||
From src/client/components/GearImage.tsx:
|
||||
```typescript
|
||||
interface GearImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
dominantColor?: string | null;
|
||||
cropZoom?: number | null;
|
||||
cropX?: number | null;
|
||||
cropY?: number | null;
|
||||
className?: string;
|
||||
cover?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
<!-- ImageCropEditor returns CropResult on save -->
|
||||
From src/client/components/ImageCropEditor.tsx:
|
||||
```typescript
|
||||
interface CropResult {
|
||||
zoom: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
// onSave: (result: CropResult) => void;
|
||||
```
|
||||
|
||||
<!-- ImageUpload current props -->
|
||||
From src/client/components/ImageUpload.tsx:
|
||||
```typescript
|
||||
interface ImageUploadProps {
|
||||
value: string | null;
|
||||
imageUrl?: string | null;
|
||||
dominantColor?: string | null;
|
||||
onChange: (filename: string | null, dominantColor?: string | null) => void;
|
||||
onCropChange?: (crop: { zoom: number; x: number; y: number }) => void;
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add local crop state to ImageUpload and wire to GearImage preview</name>
|
||||
<files>src/client/components/ImageUpload.tsx</files>
|
||||
<action>
|
||||
In ImageUpload.tsx, make these changes:
|
||||
|
||||
1. Add a local crop state to track the most recent crop values:
|
||||
```typescript
|
||||
const [localCrop, setLocalCrop] = useState<{ zoom: number; x: number; y: number } | null>(null);
|
||||
```
|
||||
|
||||
2. In the ImageCropEditor onSave handler (around line 88-91), update localCrop before calling the parent onCropChange:
|
||||
```typescript
|
||||
onSave={(result) => {
|
||||
setLocalCrop(result);
|
||||
onCropChange(result);
|
||||
setShowCropEditor(false);
|
||||
}}
|
||||
```
|
||||
|
||||
3. In the GearImage render (around line 110-114), pass localCrop values as props:
|
||||
```typescript
|
||||
<GearImage
|
||||
src={displayUrl}
|
||||
alt="Item"
|
||||
dominantColor={dominantColor}
|
||||
cropZoom={localCrop?.zoom}
|
||||
cropX={localCrop?.x}
|
||||
cropY={localCrop?.y}
|
||||
/>
|
||||
```
|
||||
|
||||
4. When the image is removed (handleRemove), also clear localCrop:
|
||||
```typescript
|
||||
function handleRemove(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setLocalPreview(null);
|
||||
setLocalCrop(null);
|
||||
onChange(null);
|
||||
}
|
||||
```
|
||||
|
||||
This ensures the GearImage preview immediately reflects crop adjustments without waiting for a server round-trip and query refetch.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bunx tsc --noEmit --pretty 2>&1 | head -30</automated>
|
||||
</verify>
|
||||
<done>After using the crop editor on an uploaded image, the GearImage preview in ImageUpload immediately shows the cropped framing. Removing the image clears both the preview and crop state.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
No new trust boundaries — this is a client-side-only state management fix within existing components.
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-29-05-01 | T (Tampering) | localCrop state | accept | Client-side display only; actual crop values are persisted via existing server mutation in parent component |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. TypeScript compiles without errors
|
||||
2. Manual: Open item in edit mode, upload image, crop it, verify preview shows crop immediately (without clicking Save)
|
||||
3. Manual: Open existing item in edit mode, click crop button, adjust, save framing — preview updates immediately
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Cropped image preview updates in edit state immediately after cropping, without needing to save the form
|
||||
- No TypeScript errors
|
||||
- Image removal clears crop state
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/29-image-presentation/29-05-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
phase: 29-image-presentation
|
||||
plan: 05
|
||||
status: complete
|
||||
gap_closure: true
|
||||
started: 2026-04-13T12:00:00Z
|
||||
completed: 2026-04-13T12:10:00Z
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Fixed cropped image preview not updating immediately in edit mode. Added `localCrop` state to `ImageUpload` that captures crop values from `ImageCropEditor` and passes them to `GearImage` as props. Previously, the preview only reflected crop settings after saving the form and refetching from the server.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Added `localCrop` useState to ImageUpload for immediate crop feedback
|
||||
- Wired ImageCropEditor onSave to set localCrop before forwarding to parent
|
||||
- Passed localCrop values (cropZoom, cropX, cropY) to GearImage preview
|
||||
- Clear localCrop on image removal to prevent stale state
|
||||
|
||||
## Key Files
|
||||
|
||||
### Modified
|
||||
- `src/client/components/ImageUpload.tsx` — local crop state + GearImage prop wiring
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- TypeScript compiles without errors (no new errors in ImageUpload.tsx)
|
||||
- Local crop state correctly flows: ImageCropEditor → localCrop → GearImage props
|
||||
- Image removal clears both preview and crop state
|
||||
|
||||
## Deviations
|
||||
|
||||
None — implemented exactly as planned.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
status: diagnosed
|
||||
phase: 29-image-presentation
|
||||
source: [29-01-SUMMARY.md, 29-02-SUMMARY.md, 29-03-SUMMARY.md, 29-04-SUMMARY.md]
|
||||
started: 2026-04-12T19:10:00Z
|
||||
updated: 2026-04-13T12:15:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Images use fit-within instead of crop
|
||||
expected: Browse any page with item/catalog cards. Images should fit inside the frame without cropping — full image visible, no parts cut off.
|
||||
result: pass
|
||||
|
||||
### 2. Dominant color background fill
|
||||
expected: Where an image doesn't fill the entire frame, the empty space is filled with a color extracted from the image (not white or gray).
|
||||
result: pass
|
||||
|
||||
### 3. Crop editor on item detail
|
||||
expected: Open an item that has an image. In edit mode, you should see a crop icon button next to the trash icon, positioned as an overlay on the image. Clicking it opens a crop editor with zoom slider.
|
||||
result: pass
|
||||
reported: "Initially reported as issue but confirmed working on re-test — false claim"
|
||||
|
||||
### 4. Crop editor on image upload
|
||||
expected: Upload a new image to an item. After the upload completes, a crop editor should appear automatically. After cropping, the preview should reflect the crop immediately.
|
||||
result: issue
|
||||
reported: "crop editor opens on upload correctly, but after cropping the cropped image isn't shown in the edit state always — after clicking save it is shown correctly"
|
||||
severity: minor
|
||||
|
||||
### 5. Crop settings persist
|
||||
expected: Adjust the crop on an item image, save it. Navigate away and come back — image displays with saved crop settings.
|
||||
result: pass
|
||||
|
||||
### 6. Consistency across surfaces
|
||||
expected: All image surfaces use the same fit-within + dominant color treatment.
|
||||
result: pass
|
||||
|
||||
## Summary
|
||||
|
||||
total: 6
|
||||
passed: 5
|
||||
issues: 1
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
- truth: "Cropped image preview should update in edit state immediately after cropping"
|
||||
status: failed
|
||||
reason: "User reported: cropped image not shown in edit state after cropping, but renders correctly after save"
|
||||
severity: minor
|
||||
test: 4
|
||||
root_cause: "ImageUpload component does not store or forward crop values to its GearImage preview after crop editor closes. onCropChange sends to server but no local state is updated. GearImage in ImageUpload receives zero crop props. Only after form save + query refetch do crop values appear."
|
||||
artifacts:
|
||||
- path: "src/client/components/ImageUpload.tsx"
|
||||
issue: "GearImage preview (line 110-114) rendered without cropZoom/cropX/cropY props; no local crop state exists"
|
||||
- path: "src/client/routes/items/$itemId.tsx"
|
||||
issue: "onCropChange (line 288-293) fires server mutation but updates no local/form state"
|
||||
missing:
|
||||
- Add local crop state in ImageUpload that gets set from crop editor result and passed as props to GearImage
|
||||
debug_session: ".planning/debug/crop-preview-edit-state.md"
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
status: partial
|
||||
phase: 30-onboarding-redesign
|
||||
source: [30-01-SUMMARY.md, 30-02-SUMMARY.md, 30-03-SUMMARY.md]
|
||||
started: 2026-04-12T19:30:00Z
|
||||
updated: 2026-04-13T12:30:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing paused — 3 items blocked by catalog seed data]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Onboarding triggers on first login
|
||||
expected: After creating a new account and signing in for the first time, a full-screen onboarding flow appears (not the old small modal wizard).
|
||||
result: pass
|
||||
|
||||
### 2. Welcome step
|
||||
expected: First screen shows a welcome message with a "Get Started" button. Full-screen, big visuals, immersive feel.
|
||||
result: pass
|
||||
|
||||
### 3. Hobby picker (required step)
|
||||
expected: Second screen shows hobby cards with icons (Bikepacking, Hiking, Climbing, Cycling, etc.). You can select one or more. This step cannot be skipped.
|
||||
result: issue
|
||||
reported: "Works but selected cards need stronger visual distinction — dark gray fill with inverted text/icon instead of just a border change."
|
||||
severity: cosmetic
|
||||
|
||||
### 4. Item browser
|
||||
expected: After picking a hobby, you see a grid of popular catalog items filtered by that hobby.
|
||||
result: blocked
|
||||
blocked_by: server
|
||||
reason: "Catalog is empty on test server — need some kind of seeding for the test env."
|
||||
|
||||
### 5. Review screen
|
||||
expected: After selecting items, a review/summary screen shows all selections grouped by category.
|
||||
result: blocked
|
||||
blocked_by: prior-phase
|
||||
reason: "Depends on test 4 — catalog seed data needed."
|
||||
|
||||
### 6. Completion and collection
|
||||
expected: After confirming, items are batch-added to collection with auto-created categories.
|
||||
result: blocked
|
||||
blocked_by: prior-phase
|
||||
reason: "Depends on test 4 — catalog seed data needed."
|
||||
|
||||
### 7. Onboarding doesn't show again
|
||||
expected: Refresh the page or sign out and back in. Onboarding does NOT appear again.
|
||||
result: pass
|
||||
|
||||
## Summary
|
||||
|
||||
total: 7
|
||||
passed: 3
|
||||
issues: 1
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 3
|
||||
|
||||
## Gaps
|
||||
|
||||
- truth: "Selected hobby cards should have strong visual distinction"
|
||||
status: failed
|
||||
reason: "User reported: selected cards need dark gray fill with inverted text/icon, not just border change"
|
||||
severity: cosmetic
|
||||
test: 3
|
||||
artifacts:
|
||||
- path: "src/client/components/onboarding/OnboardingHobbyPicker.tsx"
|
||||
issue: "Weak selected state styling"
|
||||
missing:
|
||||
- Stronger selected state styling (dark bg, inverted colors)
|
||||
33
.planning/milestones/v2.2-phases/31-mobile-polish/31-UAT.md
Normal file
33
.planning/milestones/v2.2-phases/31-mobile-polish/31-UAT.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
status: complete
|
||||
phase: 31-mobile-polish
|
||||
source: [31-01-SUMMARY.md, 31-02-SUMMARY.md]
|
||||
started: 2026-04-12T19:45:00Z
|
||||
updated: 2026-04-12T19:45:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Icon action buttons on mobile
|
||||
expected: Open an item detail page on mobile. Action buttons (Edit, Delete, Duplicate) show as icons only instead of text labels.
|
||||
result: pass
|
||||
|
||||
### 2. Icons across all detail pages
|
||||
expected: Candidate detail, setup detail, catalog item detail all have icon buttons on mobile too.
|
||||
result: pass
|
||||
|
||||
## Summary
|
||||
|
||||
total: 2
|
||||
passed: 2
|
||||
issues: 0
|
||||
pending: 0
|
||||
skipped: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
[none]
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
status: partial
|
||||
phase: 28-profile-and-logto-integration
|
||||
source: [28-01-SUMMARY.md, 28-02-SUMMARY.md, 28-03-SUMMARY.md]
|
||||
started: 2026-04-12T18:30:00Z
|
||||
updated: 2026-04-12T19:00:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Profile page navigation
|
||||
expected: Click your avatar in the top nav. The dropdown shows "Profile" above "Settings". Clicking it navigates to /profile.
|
||||
result: pass
|
||||
|
||||
### 2. Profile page sections
|
||||
expected: /profile page shows four sections: Profile Info (displayName, bio, avatar), Account Info (email, member-since date), Security (password change), and Danger Zone (delete account).
|
||||
result: pass
|
||||
|
||||
### 3. Settings page separation
|
||||
expected: /settings page shows only app preferences: weight unit, currency, import/export, API keys. No profile section.
|
||||
result: pass
|
||||
|
||||
### 4. Edit display name and bio
|
||||
expected: On /profile, change display name and bio, click Save. Success message appears. Refreshing the page shows the updated values.
|
||||
result: issue
|
||||
reported: "Save works for name/bio after Zod null fix, but avatar upload doesn't persist after save. Also needs: crop editor for avatar, larger profile pic with name/bio side-by-side layout, click-to-open modal with full-size image + Remove/Update buttons."
|
||||
severity: major
|
||||
|
||||
### 5. Email display
|
||||
expected: Account Info section shows your email address (from Logto) and a "Change" button next to it.
|
||||
result: blocked
|
||||
blocked_by: third-party
|
||||
reason: "Logto Management API returns 500 — M2M env vars (LOGTO_MANAGEMENT_API_ENDPOINT, LOGTO_M2M_APP_ID, LOGTO_M2M_APP_SECRET) not configured on test env"
|
||||
|
||||
### 6. Password change form
|
||||
expected: Security section shows a password change form. If you signed in with email+password: current password, new password, confirm new password fields. If social login only: just new password + confirm fields.
|
||||
result: blocked
|
||||
blocked_by: third-party
|
||||
reason: "Logto Management API returns 500 — M2M env vars not configured on test env"
|
||||
|
||||
### 7. Delete account UI
|
||||
expected: Danger Zone shows a red-bordered card with "Delete Account" button. Clicking it shows a confirmation dialog requiring you to type "DELETE" before proceeding.
|
||||
result: blocked
|
||||
blocked_by: third-party
|
||||
reason: "Logto Management API returns 500 — M2M env vars not configured on test env"
|
||||
|
||||
### 8. Member-since date
|
||||
expected: Account Info section shows a "Member since" date formatted nicely (e.g., "April 2026").
|
||||
result: pass
|
||||
|
||||
## Summary
|
||||
|
||||
total: 8
|
||||
passed: 4
|
||||
issues: 1
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 3
|
||||
|
||||
## Gaps
|
||||
|
||||
- truth: "Avatar upload should persist after save. Profile pic should have crop editor, larger display with side-by-side layout, and click-to-open modal."
|
||||
status: failed
|
||||
reason: "User reported: avatar upload doesn't persist. Also needs crop editor, layout redesign (larger pic, name/bio beside it), click-to-open modal with Remove/Update."
|
||||
severity: major
|
||||
test: 4
|
||||
artifacts:
|
||||
- src/client/components/ProfileSection.tsx
|
||||
missing:
|
||||
- Avatar save persistence fix
|
||||
- Crop editor integration for avatar
|
||||
- Profile layout redesign (larger pic, side-by-side, modal)
|
||||
@@ -1,115 +0,0 @@
|
||||
---
|
||||
status: complete
|
||||
phase: 29-image-presentation
|
||||
source: [29-01-SUMMARY.md, 29-02-SUMMARY.md, 29-03-SUMMARY.md, 29-04-SUMMARY.md]
|
||||
started: 2026-04-12T19:10:00Z
|
||||
updated: 2026-04-12T19:20:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Images use fit-within instead of crop
|
||||
expected: Browse any page with item/catalog cards. Images should fit inside the frame without cropping — full image visible, no parts cut off.
|
||||
result: issue
|
||||
reported: "Fit-within works on detail pages but collection view cards don't render images at all."
|
||||
severity: major
|
||||
|
||||
### 2. Dominant color background fill
|
||||
expected: Where an image doesn't fill the entire frame, the empty space is filled with a color extracted from the image (not white or gray).
|
||||
result: issue
|
||||
reported: "Background is just plain gray. Even newly uploaded images don't get a dominant color — extraction not working or color not passed to frontend."
|
||||
severity: major
|
||||
|
||||
### 3. Crop editor on item detail
|
||||
expected: Open an item that has an image. You should see an "Adjust framing" button. Clicking it opens a crop editor with zoom slider.
|
||||
result: issue
|
||||
reported: "'Adjust framing' text doesn't feel like an action. Should be an icon button, only visible in edit mode, positioned below the X icon. X icon should be a trash icon to symbolize removal."
|
||||
severity: minor
|
||||
|
||||
### 4. Crop editor on image upload
|
||||
expected: Upload a new image to an item. After the upload completes, a crop editor should appear.
|
||||
result: issue
|
||||
reported: "Crop editor doesn't open when adding an image to an existing item. Can only be edited afterward."
|
||||
severity: major
|
||||
|
||||
### 5. Crop settings persist
|
||||
expected: Adjust the crop on an item image, save it. Navigate away and come back — image displays with saved crop settings.
|
||||
result: issue
|
||||
reported: "Framing adjustment doesn't save. No error message, no console log — silently fails."
|
||||
severity: blocker
|
||||
|
||||
### 6. Consistency across surfaces
|
||||
expected: All image surfaces use the same fit-within + dominant color treatment.
|
||||
result: pass
|
||||
reported: "Consistent across all surfaces where images render (detail pages). Collection cards don't render images (see test 1)."
|
||||
|
||||
## Summary
|
||||
|
||||
total: 6
|
||||
passed: 2
|
||||
issues: 4
|
||||
pending: 0
|
||||
skipped: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
- truth: "Collection view cards should render images using GearImage component"
|
||||
status: failed
|
||||
reason: "User reported: images not rendering on collection view cards, only on detail pages"
|
||||
severity: major
|
||||
test: 1
|
||||
artifacts:
|
||||
- src/client/components/ItemCard.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
missing:
|
||||
- GearImage integration in collection card view
|
||||
|
||||
- truth: "Dominant color should be extracted on upload and used as background fill"
|
||||
status: failed
|
||||
reason: "User reported: background is plain gray even for newly uploaded images — extraction not working or color not reaching frontend"
|
||||
severity: major
|
||||
test: 2
|
||||
artifacts:
|
||||
- src/server/services/image.service.ts
|
||||
- src/client/components/GearImage.tsx
|
||||
missing:
|
||||
- Debug extractDominantColor pipeline
|
||||
- Verify color is returned from upload API and stored in DB
|
||||
- Verify frontend reads and applies dominantColor
|
||||
|
||||
- truth: "Crop editor should be an icon button visible only in edit mode, with trash icon for image removal"
|
||||
status: failed
|
||||
reason: "User reported: 'Adjust framing' text doesn't feel like an action. Should be icon, edit-mode only. X should be trash icon."
|
||||
severity: minor
|
||||
test: 3
|
||||
artifacts:
|
||||
- src/client/routes/items/$itemId.tsx
|
||||
- src/client/components/ImageCropEditor.tsx
|
||||
missing:
|
||||
- Redesign crop trigger as icon button in edit mode
|
||||
- Replace X with trash icon for image removal
|
||||
|
||||
- truth: "Crop editor should open automatically when uploading a new image"
|
||||
status: failed
|
||||
reason: "User reported: crop editor doesn't open when adding image to existing item"
|
||||
severity: major
|
||||
test: 4
|
||||
artifacts:
|
||||
- src/client/components/ImageUpload.tsx
|
||||
missing:
|
||||
- Trigger crop editor after upload in ImageUpload component
|
||||
|
||||
- truth: "Crop settings (zoom, x, y) should persist to DB and render on subsequent views"
|
||||
status: failed
|
||||
reason: "User reported: framing adjustment doesn't save, no error, no log — silent failure"
|
||||
severity: blocker
|
||||
test: 5
|
||||
artifacts:
|
||||
- src/client/routes/items/$itemId.tsx
|
||||
- src/server/routes/items.ts
|
||||
missing:
|
||||
- Debug crop save pipeline (client mutation → API → DB)
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
created: 2026-04-13T13:00:00.000Z
|
||||
title: Auth prompt sign-in button should redirect directly to Logto
|
||||
area: auth
|
||||
files:
|
||||
- src/client/components/AuthPromptModal.tsx
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
When an unauthenticated user clicks a protected action, the AuthPromptModal pops up. Clicking "Sign In" navigates to an intermediate /signin page, where they have to click again to be redirected to Logto. That's two extra clicks — the modal + the signin page — before reaching the actual login.
|
||||
|
||||
## Solution
|
||||
|
||||
Make the "Sign In" / "Sign Up" buttons in the AuthPromptModal redirect directly to the Logto OIDC login endpoint (e.g., `/api/auth/login` or wherever the OIDC redirect is triggered). Skip the intermediate page entirely — one click from modal to Logto.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
created: 2026-04-13T11:39:30.356Z
|
||||
title: Fix Add Candidate button shows wrong modal on thread page
|
||||
area: ui
|
||||
files:
|
||||
- src/client/routes/threads/$threadId.tsx
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The "Add Candidate" button at the top of the thread detail page opens a manual-add modal (plain form fields) instead of the catalog search dialogue. The FAB (floating action button) in the bottom right of the same page correctly opens the catalog search dialog where you can browse and pick from global items. Both buttons should behave the same way — showing the catalog search dialog as the primary add flow.
|
||||
|
||||
## Solution
|
||||
|
||||
Wire the top "Add Candidate" button to open the same catalog search dialog/overlay that the FAB triggers. The manual-add form should still be reachable as a fallback (e.g., "Can't find it? Add manually") but not be the default.
|
||||
1
scripts/fixing.sh
Executable file
1
scripts/fixing.sh
Executable file
@@ -0,0 +1 @@
|
||||
set -e; echo "=== Step 1: Check columns ==="; bun -e 'import { db } from "./src/db/index.ts"; import { sql } from "drizzle-orm"; const r = await db.execute(sql`SELECT column_name FROM information_schema.columns WHERE table_name = '"'"'global_items'"'"' ORDER BY ordinal_position`); console.log(r.rows.map(x => x.column_name));'; echo "=== Step 2: Push schema ==="; bun run db:push; echo "=== Step 3: Nuke data ==="; bun -e 'import { db } from "./src/db/index.ts"; import { apiKeys, categories, globalItemTags, globalItems, items, oauthCodes, oauthTokens, settings, setupItems, setups, tags, threadCandidates, threads, users } from "./src/db/schema.ts"; await db.delete(setupItems); await db.delete(setups); await db.delete(threadCandidates); await db.delete(threads); await db.delete(items); await db.delete(settings); await db.delete(apiKeys); await db.delete(oauthTokens); await db.delete(oauthCodes); await db.delete(categories); await db.delete(users); await db.delete(globalItemTags); await db.delete(tags); await db.delete(globalItems); console.log("Nuke complete");'; echo "=== Step 4: Re-seed ==="; bun run db:seed:dev; echo "=== Step 5: Verify tags ==="; bun -e 'import { db } from "./src/db/index.ts"; import { tags, globalItemTags } from "./src/db/schema.ts"; import { inArray } from "drizzle-orm"; const hobbyNames = ["bikepacking","cycling","camping","hiking","backpacking","climbing","running"]; const found = await db.select().from(tags).where(inArray(tags.name, hobbyNames)); console.log("Hobby tags:", found.map(t => t.name)); const a = await db.select().from(globalItemTags); console.log("Tag assignments:", a.length);'; echo "=== Step 6: Test query ==="; bun -e 'import { getPopularItemsByTags } from "./src/server/services/discovery.service.ts"; const r = await getPopularItemsByTags(undefined, ["bikepacking","cycling","camping"], 5); console.log("Items found:", r.length); for (const i of r) console.log(" -", i.brand, i.model);'; echo "=== Done ==="
|
||||
@@ -231,6 +231,10 @@ export function CollectionView() {
|
||||
imageUrl={item.imageUrl}
|
||||
productUrl={item.productUrl}
|
||||
brand={item.brand}
|
||||
dominantColor={item.dominantColor}
|
||||
cropZoom={item.cropZoom}
|
||||
cropX={item.cropX}
|
||||
cropY={item.cropY}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -264,8 +268,13 @@ export function CollectionView() {
|
||||
categoryName={categoryName}
|
||||
categoryIcon={categoryIcon}
|
||||
imageFilename={item.imageFilename}
|
||||
imageUrl={item.imageUrl}
|
||||
productUrl={item.productUrl}
|
||||
brand={item.brand}
|
||||
dominantColor={item.dominantColor}
|
||||
cropZoom={item.cropZoom}
|
||||
cropX={item.cropX}
|
||||
cropY={item.cropY}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ interface GearImageProps {
|
||||
export function GearImage({
|
||||
src,
|
||||
alt,
|
||||
dominantColor: _dominantColor,
|
||||
dominantColor,
|
||||
cropZoom,
|
||||
cropX,
|
||||
cropY,
|
||||
@@ -20,6 +20,9 @@ export function GearImage({
|
||||
cover = false,
|
||||
}: GearImageProps) {
|
||||
const hasCrop = cropZoom != null && cropZoom > 1;
|
||||
const bgStyle = dominantColor
|
||||
? { backgroundColor: dominantColor }
|
||||
: undefined;
|
||||
|
||||
if (cover) {
|
||||
return (
|
||||
@@ -33,24 +36,31 @@ export function GearImage({
|
||||
|
||||
if (hasCrop) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-cover ${className}`}
|
||||
style={{
|
||||
transform: `scale(${cropZoom}) translate(${cropX ?? 0}%, ${cropY ?? 0}%)`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
/>
|
||||
<div className="w-full h-full overflow-hidden" style={bgStyle}>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-cover ${className}`}
|
||||
style={{
|
||||
transform: `scale(${cropZoom}) translate(${cropX ?? 0}%, ${cropY ?? 0}%)`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-contain ${className}`}
|
||||
/>
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
style={bgStyle}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-contain ${className}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ interface ImageUploadProps {
|
||||
value: string | null;
|
||||
imageUrl?: string | null;
|
||||
dominantColor?: string | null;
|
||||
onChange: (filename: string | null) => void;
|
||||
onChange: (filename: string | null, dominantColor?: string | null) => void;
|
||||
onCropChange?: (crop: { zoom: number; x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ export function ImageUpload({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [localPreview, setLocalPreview] = useState<string | null>(null);
|
||||
const [showCropEditor, setShowCropEditor] = useState(false);
|
||||
const [localCrop, setLocalCrop] = useState<{
|
||||
zoom: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
@@ -49,8 +54,11 @@ export function ImageUpload({
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await apiUpload<{ filename: string }>("/api/images", file);
|
||||
onChange(result.filename);
|
||||
const result = await apiUpload<{
|
||||
filename: string;
|
||||
dominantColor?: string | null;
|
||||
}>("/api/images", file);
|
||||
onChange(result.filename, result.dominantColor);
|
||||
if (onCropChange) {
|
||||
setShowCropEditor(true);
|
||||
}
|
||||
@@ -67,6 +75,7 @@ export function ImageUpload({
|
||||
function handleRemove(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setLocalPreview(null);
|
||||
setLocalCrop(null);
|
||||
onChange(null);
|
||||
}
|
||||
|
||||
@@ -83,6 +92,7 @@ export function ImageUpload({
|
||||
imageUrl={displayUrl}
|
||||
dominantColor={dominantColor}
|
||||
onSave={(result) => {
|
||||
setLocalCrop(result);
|
||||
onCropChange(result);
|
||||
setShowCropEditor(false);
|
||||
}}
|
||||
@@ -108,7 +118,33 @@ export function ImageUpload({
|
||||
src={displayUrl}
|
||||
alt="Item"
|
||||
dominantColor={dominantColor}
|
||||
cropZoom={localCrop?.zoom}
|
||||
cropX={localCrop?.x}
|
||||
cropY={localCrop?.y}
|
||||
/>
|
||||
{/* Crop button */}
|
||||
{onCropChange && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowCropEditor(true);
|
||||
}}
|
||||
className="absolute top-2 right-11 w-7 h-7 flex items-center justify-center bg-white/80 hover:bg-white rounded-full text-gray-600 hover:text-gray-900 transition-colors shadow-sm"
|
||||
title="Adjust framing"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M6.13 1L6 16a2 2 0 0 0 2 2h15" />
|
||||
<path d="M1 6.13L16 6a2 2 0 0 1 2 2v15" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{/* Remove button */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -125,7 +161,7 @@ export function ImageUpload({
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -11,8 +11,9 @@ export function ProfileSection() {
|
||||
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [bio, setBio] = useState("");
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [avatarFilename, setAvatarFilename] = useState<string | null>(null);
|
||||
const [avatarDisplayUrl, setAvatarDisplayUrl] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
@@ -21,13 +22,13 @@ export function ProfileSection() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (profile && !initialized) {
|
||||
if (profile && !dirty) {
|
||||
setDisplayName(profile.displayName ?? "");
|
||||
setBio(profile.bio ?? "");
|
||||
setAvatarUrl(profile.avatarUrl ?? null);
|
||||
setInitialized(true);
|
||||
setAvatarFilename(profile.avatarUrl ?? null);
|
||||
setAvatarDisplayUrl(profile.avatarImageUrl ?? null);
|
||||
}
|
||||
}, [profile, initialized]);
|
||||
}, [profile, dirty]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -35,9 +36,10 @@ export function ProfileSection() {
|
||||
try {
|
||||
await updateProfile.mutateAsync({
|
||||
displayName: displayName.trim() || undefined,
|
||||
avatarUrl,
|
||||
avatarUrl: avatarFilename,
|
||||
bio: bio.trim() || undefined,
|
||||
});
|
||||
setDirty(false);
|
||||
setMessage({ type: "success", text: "Profile updated" });
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: (err as Error).message });
|
||||
@@ -63,12 +65,16 @@ export function ProfileSection() {
|
||||
return;
|
||||
}
|
||||
|
||||
const localPreview = URL.createObjectURL(file);
|
||||
setAvatarDisplayUrl(localPreview);
|
||||
setUploading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await apiUpload<{ filename: string }>("/api/images", file);
|
||||
setAvatarUrl(result.filename);
|
||||
setAvatarFilename(result.filename);
|
||||
setDirty(true);
|
||||
} catch {
|
||||
setAvatarDisplayUrl(null);
|
||||
setMessage({ type: "error", text: "Avatar upload failed." });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@@ -91,9 +97,9 @@ export function ProfileSection() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="relative w-16 h-16 rounded-full overflow-hidden cursor-pointer group shrink-0"
|
||||
>
|
||||
{avatarUrl ? (
|
||||
{avatarDisplayUrl ? (
|
||||
<img
|
||||
src={`/uploads/${avatarUrl}`}
|
||||
src={avatarDisplayUrl}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -144,10 +150,14 @@ export function ProfileSection() {
|
||||
>
|
||||
{uploading ? "Uploading..." : "Change avatar"}
|
||||
</button>
|
||||
{avatarUrl && (
|
||||
{avatarFilename && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAvatarUrl(null)}
|
||||
onClick={() => {
|
||||
setAvatarFilename(null);
|
||||
setAvatarDisplayUrl(null);
|
||||
setDirty(true);
|
||||
}}
|
||||
className="block text-xs text-red-500 hover:text-red-700 mt-0.5"
|
||||
>
|
||||
Remove
|
||||
@@ -175,7 +185,10 @@ export function ProfileSection() {
|
||||
id="displayName"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
maxLength={100}
|
||||
placeholder="Your display name"
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
@@ -193,7 +206,10 @@ export function ProfileSection() {
|
||||
<textarea
|
||||
id="bio"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setBio(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
placeholder="Tell others about yourself and your gear interests"
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLogout } from "../hooks/useAuth";
|
||||
import { useAuth, useLogout } from "../hooks/useAuth";
|
||||
import { usePublicProfile } from "../hooks/useProfile";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
|
||||
export function UserMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { logout } = useLogout();
|
||||
const { data: auth } = useAuth();
|
||||
const userId = auth?.user?.id ? Number(auth.user.id) : null;
|
||||
const { data: profile } = usePublicProfile(userId);
|
||||
const avatarUrl = profile?.avatarImageUrl;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -24,9 +29,17 @@ export function UserMenu() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors overflow-hidden"
|
||||
>
|
||||
<LucideIcon name="circle-user" size={22} />
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Profile"
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<LucideIcon name="circle-user" size={22} />
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1 w-40 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
||||
|
||||
@@ -21,14 +21,26 @@ export function HobbyCard({
|
||||
onClick={onClick}
|
||||
className={`w-40 h-40 flex flex-col items-center justify-center gap-3 p-5 rounded-2xl cursor-pointer transition-all ${
|
||||
selected
|
||||
? "border-gray-700 ring-2 ring-gray-700/20 bg-white border"
|
||||
? "bg-gray-800 border border-gray-800 ring-2 ring-gray-700/20"
|
||||
: "bg-gray-50 border border-gray-200 hover:border-gray-300 hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<LucideIcon name={icon} size={32} className="text-gray-700" />
|
||||
<LucideIcon
|
||||
name={icon}
|
||||
size={32}
|
||||
className={selected ? "text-white" : "text-gray-700"}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-semibold text-gray-900">{name}</div>
|
||||
<div className="text-xs text-gray-400">{descriptor}</div>
|
||||
<div
|
||||
className={`text-sm font-semibold ${selected ? "text-white" : "text-gray-900"}`}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${selected ? "text-gray-300" : "text-gray-400"}`}
|
||||
>
|
||||
{descriptor}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,28 @@ export function OnboardingItemBrowser({
|
||||
|
||||
const hasItems = items && items.length > 0;
|
||||
|
||||
// Group items by category, cap at 5 categories with 4 items each
|
||||
const MAX_CATEGORIES = 5;
|
||||
const MAX_PER_CATEGORY = 4;
|
||||
|
||||
const allGrouped = hasItems
|
||||
? items.reduce<Record<string, typeof items>>((acc, item) => {
|
||||
const cat = item.category || "other";
|
||||
const label = cat.charAt(0).toUpperCase() + cat.slice(1);
|
||||
if (!acc[label]) acc[label] = [];
|
||||
acc[label].push(item);
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
|
||||
// Take top categories by item count, limit items per category
|
||||
const categories = Object.keys(allGrouped)
|
||||
.sort((a, b) => allGrouped[b].length - allGrouped[a].length)
|
||||
.slice(0, MAX_CATEGORIES);
|
||||
const grouped = Object.fromEntries(
|
||||
categories.map((cat) => [cat, allGrouped[cat].slice(0, MAX_PER_CATEGORY)]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center min-h-screen px-8 py-16">
|
||||
<div className="max-w-5xl w-full text-center">
|
||||
@@ -51,23 +73,30 @@ export function OnboardingItemBrowser({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && hasItems && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
|
||||
{items.map((item) => (
|
||||
<SelectableItemCard
|
||||
key={item.id}
|
||||
brand={item.brand}
|
||||
model={item.model}
|
||||
imageUrl={item.imageUrl}
|
||||
weightGrams={item.weightGrams}
|
||||
priceCents={item.priceCents}
|
||||
ownerCount={item.ownerCount}
|
||||
selected={selectedItemIds.has(item.id)}
|
||||
onClick={() => onToggleItem(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading &&
|
||||
hasItems &&
|
||||
categories.map((cat) => (
|
||||
<div key={cat} className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 text-left mb-3">
|
||||
{cat}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{grouped[cat].map((item) => (
|
||||
<SelectableItemCard
|
||||
key={item.id}
|
||||
brand={item.brand}
|
||||
model={item.model}
|
||||
imageUrl={item.imageUrl}
|
||||
weightGrams={item.weightGrams}
|
||||
priceCents={item.priceCents}
|
||||
ownerCount={item.ownerCount}
|
||||
selected={selectedItemIds.has(item.id)}
|
||||
onClick={() => onToggleItem(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{hasItems && selectedItemIds.size > 0 && (
|
||||
|
||||
@@ -22,7 +22,7 @@ export function SelectableItemCard({
|
||||
selected,
|
||||
onClick,
|
||||
}: SelectableItemCardProps) {
|
||||
const { formatWeight, formatPrice } = useFormatters();
|
||||
const { weight: formatWeight, price: formatPrice } = useFormatters();
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -20,9 +20,13 @@ export function useChangeEmail() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { newEmail: string }) =>
|
||||
apiPost<{ ok: boolean }>("/api/account/email", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
apiPost<{ ok: boolean; email: string }>("/api/account/email", data),
|
||||
onSuccess: (_data, variables) => {
|
||||
// Update auth cache with new email since the OIDC session still has the old one
|
||||
queryClient.setQueryData(["auth"], (old: any) => {
|
||||
if (!old?.user) return old;
|
||||
return { ...old, user: { ...old.user, email: variables.newEmail } };
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ interface PublicProfile {
|
||||
id: number;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
avatarImageUrl: string | null;
|
||||
bio: string | null;
|
||||
setups: { id: number; name: string; createdAt: string }[];
|
||||
}
|
||||
|
||||
@@ -273,9 +273,24 @@ function ItemDetail() {
|
||||
<ImageUpload
|
||||
value={form.imageFilename}
|
||||
imageUrl={imageUrl}
|
||||
onChange={(filename) =>
|
||||
setForm((f) => ({ ...f, imageFilename: filename }))
|
||||
}
|
||||
dominantColor={item.dominantColor}
|
||||
onChange={(filename, dominantColor) => {
|
||||
setForm((f) => ({ ...f, imageFilename: filename }));
|
||||
if (dominantColor) {
|
||||
updateItem.mutate({
|
||||
id: item.id,
|
||||
dominantColor,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCropChange={(crop) => {
|
||||
updateItem.mutate({
|
||||
id: item.id,
|
||||
cropZoom: crop.zoom,
|
||||
cropX: crop.x,
|
||||
cropY: crop.y,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : editingCrop && imageUrl ? (
|
||||
@@ -300,44 +315,33 @@ function ItemDetail() {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="aspect-[4/3] rounded-xl overflow-hidden mb-2"
|
||||
style={{
|
||||
backgroundColor: imageUrl
|
||||
? imageContainerBg(item.dominantColor)
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<GearImage
|
||||
src={imageUrl}
|
||||
alt={item.name}
|
||||
dominantColor={item.dominantColor}
|
||||
cropZoom={item.cropZoom}
|
||||
cropX={item.cropX}
|
||||
cropY={item.cropY}
|
||||
<div
|
||||
className="aspect-[4/3] rounded-xl overflow-hidden mb-2"
|
||||
style={{
|
||||
backgroundColor: imageUrl
|
||||
? imageContainerBg(item.dominantColor)
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<GearImage
|
||||
src={imageUrl}
|
||||
alt={item.name}
|
||||
dominantColor={item.dominantColor}
|
||||
cropZoom={item.cropZoom}
|
||||
cropX={item.cropX}
|
||||
cropY={item.cropY}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center">
|
||||
<LucideIcon
|
||||
name={item.categoryIcon}
|
||||
size={64}
|
||||
className="text-gray-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center">
|
||||
<LucideIcon
|
||||
name={item.categoryIcon}
|
||||
size={64}
|
||||
className="text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{imageUrl && !isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingCrop(true)}
|
||||
className="mb-4 text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Adjust framing
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header / Name */}
|
||||
|
||||
@@ -52,9 +52,9 @@ function PublicProfilePage() {
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||
{/* Profile header */}
|
||||
<div className="flex items-center gap-5 mb-8">
|
||||
{profile.avatarUrl ? (
|
||||
{profile.avatarImageUrl ? (
|
||||
<img
|
||||
src={`/uploads/${profile.avatarUrl}`}
|
||||
src={profile.avatarImageUrl}
|
||||
alt={displayName}
|
||||
className="w-20 h-20 rounded-full object-cover"
|
||||
/>
|
||||
|
||||
@@ -369,36 +369,181 @@ export const DEV_GLOBAL_ITEMS = [
|
||||
// Maps global item index -> tag names. Tags are seeded by seedGlobalItems().
|
||||
|
||||
export const DEV_TAG_ASSIGNMENTS = [
|
||||
{ globalItemIndex: 0, tagNames: ["saddlebag", "bike-bag"] },
|
||||
{ globalItemIndex: 1, tagNames: ["handlebar-bag", "bike-bag"] },
|
||||
{ globalItemIndex: 2, tagNames: ["framebag", "bike-bag"] },
|
||||
{ globalItemIndex: 3, tagNames: ["handlebar-bag", "bike-bag"] },
|
||||
{ globalItemIndex: 4, tagNames: ["framebag", "bike-bag"] },
|
||||
{ globalItemIndex: 5, tagNames: ["top-tube-bag", "bike-bag"] },
|
||||
{ globalItemIndex: 6, tagNames: ["tent"] },
|
||||
{ globalItemIndex: 7, tagNames: ["tent"] },
|
||||
{ globalItemIndex: 8, tagNames: ["tent"] },
|
||||
{ globalItemIndex: 9, tagNames: ["tent"] },
|
||||
{ globalItemIndex: 10, tagNames: ["quilt"] },
|
||||
{ globalItemIndex: 11, tagNames: ["sleeping-pad"] },
|
||||
{ globalItemIndex: 12, tagNames: ["sleeping-pad"] },
|
||||
{ globalItemIndex: 13, tagNames: ["pillow"] },
|
||||
{ globalItemIndex: 14, tagNames: ["sleeping-bag"] },
|
||||
{ globalItemIndex: 15, tagNames: ["stove"] },
|
||||
{ globalItemIndex: 16, tagNames: ["stove"] },
|
||||
{ globalItemIndex: 17, tagNames: ["cookware", "mug"] },
|
||||
{ globalItemIndex: 18, tagNames: ["cookware"] },
|
||||
{ globalItemIndex: 19, tagNames: ["stove"] },
|
||||
{ globalItemIndex: 20, tagNames: ["headlamp"] },
|
||||
{ globalItemIndex: 21, tagNames: ["bike-light"] },
|
||||
{ globalItemIndex: 22, tagNames: ["headlamp"] },
|
||||
{ globalItemIndex: 29, tagNames: ["water-filter"] },
|
||||
{ globalItemIndex: 30, tagNames: ["water-filter"] },
|
||||
{ globalItemIndex: 31, tagNames: ["water-bottle"] },
|
||||
{ globalItemIndex: 32, tagNames: ["multi-tool", "repair-kit"] },
|
||||
{ globalItemIndex: 33, tagNames: ["rain-jacket"] },
|
||||
{ globalItemIndex: 34, tagNames: ["bike-computer", "gps"] },
|
||||
{ globalItemIndex: 35, tagNames: ["handlebar-bag", "bike-bag", "dry-bag"] },
|
||||
// Bags — bikepacking/cycling gear
|
||||
{
|
||||
globalItemIndex: 0,
|
||||
tagNames: ["saddlebag", "bike-bag", "bikepacking", "cycling"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 1,
|
||||
tagNames: ["handlebar-bag", "bike-bag", "bikepacking", "cycling"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 2,
|
||||
tagNames: ["framebag", "bike-bag", "bikepacking", "cycling"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 3,
|
||||
tagNames: ["handlebar-bag", "bike-bag", "bikepacking", "cycling"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 4,
|
||||
tagNames: ["framebag", "bike-bag", "bikepacking", "cycling"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 5,
|
||||
tagNames: ["top-tube-bag", "bike-bag", "bikepacking", "cycling"],
|
||||
},
|
||||
// Shelter — camping/hiking/bikepacking
|
||||
{
|
||||
globalItemIndex: 6,
|
||||
tagNames: ["tent", "camping", "hiking", "bikepacking", "backpacking"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 7,
|
||||
tagNames: ["tent", "camping", "hiking", "bikepacking", "backpacking"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 8,
|
||||
tagNames: ["tent", "camping", "hiking", "backpacking"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 9,
|
||||
tagNames: [
|
||||
"tent",
|
||||
"camping",
|
||||
"hiking",
|
||||
"backpacking",
|
||||
"climbing",
|
||||
"mountaineering",
|
||||
],
|
||||
},
|
||||
// Sleep — camping/hiking/bikepacking
|
||||
{
|
||||
globalItemIndex: 10,
|
||||
tagNames: ["quilt", "camping", "hiking", "bikepacking", "backpacking"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 11,
|
||||
tagNames: [
|
||||
"sleeping-pad",
|
||||
"camping",
|
||||
"hiking",
|
||||
"bikepacking",
|
||||
"backpacking",
|
||||
],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 12,
|
||||
tagNames: ["sleeping-pad", "camping", "hiking", "backpacking"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 13,
|
||||
tagNames: ["pillow", "camping", "hiking", "bikepacking", "backpacking"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 14,
|
||||
tagNames: ["sleeping-bag", "camping", "hiking", "backpacking", "climbing"],
|
||||
},
|
||||
// Cooking — camping/hiking/bikepacking
|
||||
{
|
||||
globalItemIndex: 15,
|
||||
tagNames: ["stove", "camping", "hiking", "bikepacking", "backpacking"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 16,
|
||||
tagNames: ["stove", "camping", "hiking", "backpacking"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 17,
|
||||
tagNames: ["cookware", "mug", "camping", "hiking", "bikepacking"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 18,
|
||||
tagNames: ["cookware", "camping", "hiking", "backpacking"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 19,
|
||||
tagNames: ["stove", "camping", "hiking", "backpacking", "climbing"],
|
||||
},
|
||||
// Lighting — general outdoor
|
||||
{
|
||||
globalItemIndex: 20,
|
||||
tagNames: [
|
||||
"headlamp",
|
||||
"camping",
|
||||
"hiking",
|
||||
"climbing",
|
||||
"backpacking",
|
||||
"running",
|
||||
"trail-running",
|
||||
],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 21,
|
||||
tagNames: [
|
||||
"bike-light",
|
||||
"bikepacking",
|
||||
"cycling",
|
||||
"road-cycling",
|
||||
"gravel",
|
||||
],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 22,
|
||||
tagNames: ["headlamp", "camping", "hiking", "climbing", "backpacking"],
|
||||
},
|
||||
// Water — hiking/camping/bikepacking
|
||||
{
|
||||
globalItemIndex: 29,
|
||||
tagNames: [
|
||||
"water-filter",
|
||||
"hiking",
|
||||
"camping",
|
||||
"bikepacking",
|
||||
"backpacking",
|
||||
],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 30,
|
||||
tagNames: ["water-filter", "hiking", "camping", "backpacking"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 31,
|
||||
tagNames: ["water-bottle", "hiking", "camping", "cycling", "running"],
|
||||
},
|
||||
// Tools — bikepacking/cycling
|
||||
{
|
||||
globalItemIndex: 32,
|
||||
tagNames: ["multi-tool", "repair-kit", "bikepacking", "cycling"],
|
||||
},
|
||||
// Clothing — general outdoor
|
||||
{
|
||||
globalItemIndex: 33,
|
||||
tagNames: [
|
||||
"rain-jacket",
|
||||
"hiking",
|
||||
"camping",
|
||||
"bikepacking",
|
||||
"climbing",
|
||||
"running",
|
||||
],
|
||||
},
|
||||
// Electronics — bikepacking/cycling
|
||||
{
|
||||
globalItemIndex: 34,
|
||||
tagNames: ["bike-computer", "gps", "bikepacking", "cycling", "gravel"],
|
||||
},
|
||||
{
|
||||
globalItemIndex: 35,
|
||||
tagNames: [
|
||||
"handlebar-bag",
|
||||
"bike-bag",
|
||||
"dry-bag",
|
||||
"bikepacking",
|
||||
"cycling",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
// ── Category name mapping (for FK lookups by category name) ────────
|
||||
|
||||
@@ -5,6 +5,18 @@ import { globalItems, tags } from "./schema.ts";
|
||||
type Db = typeof prodDb;
|
||||
|
||||
const SEED_TAGS = [
|
||||
// Hobby / activity tags (used by onboarding hobby picker)
|
||||
"bikepacking",
|
||||
"cycling",
|
||||
"hiking",
|
||||
"backpacking",
|
||||
"camping",
|
||||
"climbing",
|
||||
"mountaineering",
|
||||
"road-cycling",
|
||||
"gravel",
|
||||
"running",
|
||||
"trail-running",
|
||||
// Bag types
|
||||
"handlebar-bag",
|
||||
"framebag",
|
||||
@@ -58,14 +70,16 @@ const SEED_TAGS = [
|
||||
|
||||
/**
|
||||
* Seed curated tags for outdoor/adventure gear.
|
||||
* Idempotent: skips if any tags already exist.
|
||||
* Idempotent: inserts only tags that don't already exist.
|
||||
*/
|
||||
export async function seedTags(db: Db = prodDb) {
|
||||
const existing = await db.select().from(tags).limit(1);
|
||||
if (existing.length > 0) return;
|
||||
const existing = await db.select().from(tags);
|
||||
const existingNames = new Set(existing.map((t) => t.name));
|
||||
|
||||
for (const name of SEED_TAGS) {
|
||||
await db.insert(tags).values({ name });
|
||||
if (!existingNames.has(name)) {
|
||||
await db.insert(tags).values({ name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ app.post("/email", zValidator("json", changeEmailSchema), async (c) => {
|
||||
|
||||
const logtoSub = await getLogtoSub(db, userId);
|
||||
await logtoClient.updateEmail(logtoSub, newEmail);
|
||||
return c.json({ ok: true });
|
||||
return c.json({ ok: true, email: newEmail });
|
||||
});
|
||||
|
||||
// ── Has Password ────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
listApiKeys,
|
||||
} from "../services/auth.service.ts";
|
||||
import { updateProfile } from "../services/profile.service.ts";
|
||||
import { getImageUrl } from "../services/storage.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any; userId?: number } };
|
||||
|
||||
@@ -97,7 +98,12 @@ app.put(
|
||||
const data = c.req.valid("json");
|
||||
const updated = await updateProfile(db, userId, data);
|
||||
if (!updated) return c.json({ error: "User not found" }, 404);
|
||||
return c.json(updated);
|
||||
return c.json({
|
||||
...updated,
|
||||
avatarImageUrl: updated.avatarUrl
|
||||
? await getImageUrl(updated.avatarUrl)
|
||||
: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
getRecentGlobalItems,
|
||||
getTrendingCategories,
|
||||
} from "../services/discovery.service.ts";
|
||||
import { withImageUrls } from "../services/storage.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any } };
|
||||
|
||||
@@ -52,8 +51,7 @@ app.get("/popular-items", async (c) => {
|
||||
}
|
||||
|
||||
const results = await getPopularItemsByTags(db, tagNames, limit);
|
||||
const enriched = await withImageUrls(results);
|
||||
return c.json({ items: enriched });
|
||||
return c.json({ items: results });
|
||||
});
|
||||
|
||||
export { app as discoveryRoutes };
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { Hono } from "hono";
|
||||
import { parseId } from "../lib/params.ts";
|
||||
import { getPublicProfile } from "../services/profile.service.ts";
|
||||
import { getImageUrl } from "../services/storage.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any; userId?: number } };
|
||||
|
||||
async function enrichAvatarUrl<T extends { avatarUrl: string | null }>(
|
||||
record: T,
|
||||
): Promise<T & { avatarImageUrl: string | null }> {
|
||||
return {
|
||||
...record,
|
||||
avatarImageUrl: record.avatarUrl
|
||||
? await getImageUrl(record.avatarUrl)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const app = new Hono<Env>();
|
||||
|
||||
// GET /:id/profile — Public profile (no auth required)
|
||||
@@ -15,7 +27,7 @@ app.get("/:id/profile", async (c) => {
|
||||
const profile = await getPublicProfile(db, id);
|
||||
if (!profile) return c.json({ error: "User not found" }, 404);
|
||||
|
||||
return c.json(profile);
|
||||
return c.json(await enrichAvatarUrl(profile));
|
||||
});
|
||||
|
||||
export { app as profileRoutes };
|
||||
|
||||
@@ -150,7 +150,7 @@ export async function getPopularItemsByTags(
|
||||
category: string | null;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
imageFilename: string | null;
|
||||
imageUrl: string | null;
|
||||
description: string | null;
|
||||
ownerCount: number;
|
||||
}>
|
||||
@@ -165,7 +165,7 @@ export async function getPopularItemsByTags(
|
||||
category: globalItems.category,
|
||||
weightGrams: globalItems.weightGrams,
|
||||
priceCents: globalItems.priceCents,
|
||||
imageFilename: globalItems.imageFilename,
|
||||
imageUrl: globalItems.imageUrl,
|
||||
description: globalItems.description,
|
||||
ownerCount: sql<number>`CAST(COUNT(DISTINCT ${items.id}) AS INT)`,
|
||||
})
|
||||
|
||||
@@ -38,6 +38,10 @@ export async function getAllItems(db: Db, userId: number) {
|
||||
brand: sql<
|
||||
string | null
|
||||
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
|
||||
dominantColor: items.dominantColor,
|
||||
cropZoom: items.cropZoom,
|
||||
cropX: items.cropX,
|
||||
cropY: items.cropY,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
@@ -82,6 +86,10 @@ export async function getItemById(db: Db, userId: number, id: number) {
|
||||
brand: sql<
|
||||
string | null
|
||||
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
|
||||
dominantColor: items.dominantColor,
|
||||
cropZoom: items.cropZoom,
|
||||
cropX: items.cropX,
|
||||
cropY: items.cropY,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
@@ -154,6 +162,10 @@ export async function updateItem(
|
||||
globalItemId: number;
|
||||
purchasePriceCents: number;
|
||||
brand: string;
|
||||
dominantColor: string | null;
|
||||
cropZoom: number | null;
|
||||
cropX: number | null;
|
||||
cropY: number | null;
|
||||
}>,
|
||||
) {
|
||||
// Check if item exists and belongs to user
|
||||
|
||||
@@ -91,7 +91,17 @@ export class LogtoManagementClient {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Logto M2M token request failed: HTTP ${res.status}`);
|
||||
const errorBody = await res.text();
|
||||
console.error(
|
||||
`[Logto M2M] Token request failed: HTTP ${res.status}`,
|
||||
`\n URL: ${tokenUrl}`,
|
||||
`\n Resource: ${config.apiResource}`,
|
||||
`\n App ID: ${config.m2mAppId.slice(0, 8)}...`,
|
||||
`\n Response: ${errorBody}`,
|
||||
);
|
||||
throw new Error(
|
||||
`Logto M2M token request failed: HTTP ${res.status} — ${errorBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
|
||||
@@ -103,7 +103,8 @@ export async function completeOnboarding(
|
||||
userId,
|
||||
weightGrams: gi.weightGrams,
|
||||
priceCents: gi.priceCents,
|
||||
imageFilename: gi.imageFilename,
|
||||
// globalItem images are external URLs, not S3 filenames — skip copying
|
||||
// The item inherits its image via globalItemId linkage
|
||||
globalItemId: gi.id,
|
||||
});
|
||||
itemsCreated++;
|
||||
|
||||
Reference in New Issue
Block a user