Compare commits
59 Commits
3906273a10
...
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 | |||
| 9318bc56ac | |||
| 4241023950 | |||
| cba3804b31 | |||
| 23cfbf7e4b | |||
| ddb76fd229 | |||
| 84205563a7 | |||
| 094301cc92 | |||
| d749e41f7b | |||
| a0c01d388c | |||
| 15c9f94d67 | |||
| 3870662dc6 | |||
| 115766cf60 | |||
| 0db8771574 | |||
| 5c18a3cd6c | |||
| 1de91bc024 | |||
| 9448571993 | |||
| 5b35e60477 | |||
| 9da4c8435c | |||
| d64708056f | |||
| 2347d49b69 | |||
| d37e64e71c | |||
| edd1cdde68 |
@@ -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)
|
||||
- [ ] **Phase 30: Onboarding Redesign** — Catalog-driven onboarding replacing manual entry, visual refresh to match current UI (promotes 999.2)
|
||||
- [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,8 +224,8 @@ 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 |
|
||||
| 30. Onboarding Redesign | v2.2 | TBD | Pending | — |
|
||||
| 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 | — |
|
||||
|
||||
@@ -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:18:59.511Z"
|
||||
last_activity: 2026-04-12
|
||||
last_updated: "2026-04-13T13:55:33.612Z"
|
||||
last_activity: 2026-04-13
|
||||
progress:
|
||||
total_phases: 36
|
||||
completed_phases: 23
|
||||
total_plans: 64
|
||||
completed_plans: 62
|
||||
completed_phases: 24
|
||||
total_plans: 68
|
||||
completed_plans: 66
|
||||
percent: 97
|
||||
---
|
||||
|
||||
@@ -21,14 +21,14 @@ progress:
|
||||
See: .planning/PROJECT.md (updated 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.
|
||||
**Current focus:** Phase 31 — Mobile Polish
|
||||
**Current focus:** Phase 30 — Onboarding Redesign
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 999.1
|
||||
Phase: 31
|
||||
Plan: Not started
|
||||
Status: Executing Phase 31
|
||||
Last activity: 2026-04-12
|
||||
Status: Executing Phase 30
|
||||
Last activity: 2026-04-13
|
||||
|
||||
Progress: [░░░░░░░░░░] 0%
|
||||
|
||||
@@ -36,7 +36,7 @@ Progress: [░░░░░░░░░░] 0%
|
||||
|
||||
**Velocity:**
|
||||
|
||||
- Total plans completed: 64 (all milestones through v2.0)
|
||||
- Total plans completed: 67 (all milestones through v2.0)
|
||||
- v1.3: 6 plans across 4 phases (2026-03-16 to 2026-04-08)
|
||||
- v2.0: 32 plans across 10 phases (2026-03-17 to 2026-04-08)
|
||||
|
||||
@@ -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,436 @@
|
||||
---
|
||||
phase: 30
|
||||
plan: 01
|
||||
type: backend
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/shared/hobbyConfig.ts
|
||||
- src/server/services/discovery.service.ts
|
||||
- src/server/routes/discovery.ts
|
||||
- src/server/services/onboarding.service.ts
|
||||
- src/server/routes/onboarding.ts
|
||||
- src/server/index.ts
|
||||
- src/shared/schemas.ts
|
||||
autonomous: true
|
||||
requirements: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the backend infrastructure for catalog-driven onboarding: a shared hobby-to-tag mapping config, a popular-items-by-tags discovery endpoint, and a transactional batch onboarding completion endpoint that creates user items from selected global catalog items with auto-created categories.
|
||||
</objective>
|
||||
|
||||
<tasks>
|
||||
|
||||
### Task 1: Create shared hobby configuration
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/shared/schemas.ts
|
||||
- src/client/lib/iconData.ts
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/shared/hobbyConfig.ts` with a static hobby-to-tag mapping and metadata for the hobby picker UI:
|
||||
|
||||
```ts
|
||||
export interface HobbyDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string; // Lucide icon name from iconData
|
||||
descriptor: string; // Short tagline shown on card
|
||||
tags: string[]; // Catalog tags to query for this hobby
|
||||
}
|
||||
|
||||
export const HOBBIES: HobbyDefinition[] = [
|
||||
{ id: "bikepacking", name: "Bikepacking", icon: "bike", descriptor: "Ride & camp", tags: ["bikepacking", "cycling", "camping"] },
|
||||
{ id: "hiking", name: "Hiking", icon: "mountain", descriptor: "Trail gear", tags: ["hiking", "backpacking", "camping"] },
|
||||
{ id: "climbing", name: "Climbing", icon: "mountain-snow", descriptor: "Vertical kit", tags: ["climbing", "mountaineering"] },
|
||||
{ id: "cycling", name: "Cycling", icon: "circle-dot", descriptor: "Road & gravel", tags: ["cycling", "road-cycling", "gravel"] },
|
||||
{ id: "camping", name: "Camping", icon: "tent", descriptor: "Base camp", tags: ["camping", "backpacking"] },
|
||||
{ id: "running", name: "Running", icon: "footprints", descriptor: "Run light", tags: ["running", "trail-running"] },
|
||||
];
|
||||
|
||||
/** Deduplicate and collect all tags for the given hobby IDs */
|
||||
export function getTagsForHobbies(hobbyIds: string[]): string[] {
|
||||
const tagSet = new Set<string>();
|
||||
for (const id of hobbyIds) {
|
||||
const hobby = HOBBIES.find((h) => h.id === id);
|
||||
if (hobby) hobby.tags.forEach((t) => tagSet.add(t));
|
||||
}
|
||||
return [...tagSet];
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "export const HOBBIES" src/shared/hobbyConfig.ts && grep "getTagsForHobbies" src/shared/hobbyConfig.ts && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `src/shared/hobbyConfig.ts` exports `HOBBIES` array with 6 hobby definitions
|
||||
- Each hobby has `id`, `name`, `icon`, `descriptor`, `tags` fields
|
||||
- `getTagsForHobbies` function accepts string array and returns deduplicated tag names
|
||||
- Icons use valid Lucide icon names: `bike`, `mountain`, `mountain-snow`, `circle-dot`, `tent`, `footprints`
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 2: Add popular-items-by-tags query to discovery service
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/server/services/discovery.service.ts
|
||||
- src/db/schema.ts
|
||||
</read_first>
|
||||
<action>
|
||||
Add a new function `getPopularItemsByTags` to `src/server/services/discovery.service.ts`:
|
||||
|
||||
```ts
|
||||
import { globalItems, globalItemTags, items, tags } from "../../db/schema.ts";
|
||||
import { inArray } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Get popular global items filtered by tag names, ordered by owner count descending.
|
||||
* Owner count = number of user items linked to each global item via globalItemId.
|
||||
*/
|
||||
export async function getPopularItemsByTags(
|
||||
db: Db = prodDb,
|
||||
tagNames: string[],
|
||||
limit = 24,
|
||||
): Promise<Array<{
|
||||
id: number;
|
||||
brand: string | null;
|
||||
model: string;
|
||||
category: string | null;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
imageFilename: string | null;
|
||||
description: string | null;
|
||||
ownerCount: number;
|
||||
}>> {
|
||||
if (tagNames.length === 0) return [];
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: globalItems.id,
|
||||
brand: globalItems.brand,
|
||||
model: globalItems.model,
|
||||
category: globalItems.category,
|
||||
weightGrams: globalItems.weightGrams,
|
||||
priceCents: globalItems.priceCents,
|
||||
imageFilename: globalItems.imageFilename,
|
||||
description: globalItems.description,
|
||||
ownerCount: sql<number>`CAST(COUNT(DISTINCT ${items.id}) AS INT)`,
|
||||
})
|
||||
.from(globalItems)
|
||||
.innerJoin(globalItemTags, eq(globalItemTags.globalItemId, globalItems.id))
|
||||
.innerJoin(tags, eq(tags.id, globalItemTags.tagId))
|
||||
.leftJoin(items, eq(items.globalItemId, globalItems.id))
|
||||
.where(inArray(tags.name, tagNames))
|
||||
.groupBy(globalItems.id)
|
||||
.orderBy(desc(sql<number>`COUNT(DISTINCT ${items.id})`), desc(globalItems.id))
|
||||
.limit(limit);
|
||||
|
||||
return rows;
|
||||
}
|
||||
```
|
||||
|
||||
Add `inArray` to the drizzle-orm import at the top of the file if not already present. Add `globalItemTags`, `tags` to the schema import.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "getPopularItemsByTags" src/server/services/discovery.service.ts && grep "inArray" src/server/services/discovery.service.ts && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `getPopularItemsByTags` function exported from discovery.service.ts
|
||||
- Accepts `tagNames: string[]` and `limit` parameter
|
||||
- Uses INNER JOIN on globalItemTags + tags to filter by tag names
|
||||
- Uses LEFT JOIN on items to count owners via `globalItemId`
|
||||
- Orders by ownerCount DESC, globalItems.id DESC
|
||||
- Returns empty array for empty tagNames input
|
||||
- Returns fields: id, brand, model, category, weightGrams, priceCents, imageFilename, description, ownerCount
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 3: Add popular-items endpoint to discovery routes
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/server/routes/discovery.ts
|
||||
- src/server/services/discovery.service.ts
|
||||
</read_first>
|
||||
<action>
|
||||
Add a new GET endpoint to `src/server/routes/discovery.ts`:
|
||||
|
||||
```ts
|
||||
// GET /api/discovery/popular-items?tags=bikepacking,hiking&limit=24
|
||||
app.get("/popular-items", async (c) => {
|
||||
const database = c.get("db");
|
||||
const tagsParam = c.req.query("tags") || "";
|
||||
const limitParam = c.req.query("limit");
|
||||
const tagNames = tagsParam.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 50) : 24;
|
||||
|
||||
if (tagNames.length === 0) {
|
||||
return c.json({ items: [] });
|
||||
}
|
||||
|
||||
const results = await getPopularItemsByTags(database, tagNames, limit);
|
||||
const enriched = await withImageUrls(results);
|
||||
return c.json({ items: enriched });
|
||||
});
|
||||
```
|
||||
|
||||
Import `getPopularItemsByTags` from the discovery service. Import `withImageUrls` from storage service (same pattern as other discovery endpoints).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "popular-items" src/server/routes/discovery.ts && grep "getPopularItemsByTags" src/server/routes/discovery.ts && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `GET /api/discovery/popular-items` endpoint exists in discovery.ts
|
||||
- Accepts `tags` query param (comma-separated) and optional `limit` (max 50, default 24)
|
||||
- Returns `{ items: [...] }` with image URLs enriched via `withImageUrls`
|
||||
- Returns `{ items: [] }` when no tags provided
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 4: Create onboarding service with batch item creation
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/server/services/item.service.ts
|
||||
- src/server/services/settings.service.ts
|
||||
- src/db/schema.ts
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/server/services/onboarding.service.ts`:
|
||||
|
||||
```ts
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { categories, globalItems, items, settings } from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
interface OnboardingResult {
|
||||
itemsCreated: number;
|
||||
categoriesCreated: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete onboarding by batch-creating user items from selected global catalog items.
|
||||
* Auto-creates categories based on the global items' category field.
|
||||
* Sets onboardingComplete setting to "true".
|
||||
* Runs in a single transaction — all-or-nothing.
|
||||
*/
|
||||
export async function completeOnboarding(
|
||||
db: Db = prodDb,
|
||||
userId: number,
|
||||
globalItemIds: number[],
|
||||
): Promise<OnboardingResult> {
|
||||
if (globalItemIds.length === 0) {
|
||||
// No items selected — just mark complete
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ userId, key: "onboardingComplete", value: "true" })
|
||||
.onConflictDoUpdate({
|
||||
target: [settings.userId, settings.key],
|
||||
set: { value: "true" },
|
||||
});
|
||||
return { itemsCreated: 0, categoriesCreated: [] };
|
||||
}
|
||||
|
||||
// Fetch all selected global items
|
||||
const selectedItems = await db
|
||||
.select()
|
||||
.from(globalItems)
|
||||
.where(inArray(globalItems.id, globalItemIds));
|
||||
|
||||
if (selectedItems.length === 0) {
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ userId, key: "onboardingComplete", value: "true" })
|
||||
.onConflictDoUpdate({
|
||||
target: [settings.userId, settings.key],
|
||||
set: { value: "true" },
|
||||
});
|
||||
return { itemsCreated: 0, categoriesCreated: [] };
|
||||
}
|
||||
|
||||
// Collect unique category names from global items
|
||||
const categoryNames = [...new Set(
|
||||
selectedItems
|
||||
.map((gi) => gi.category)
|
||||
.filter((c): c is string => c !== null && c.trim() !== "")
|
||||
)];
|
||||
|
||||
// Get existing user categories
|
||||
const existingCats = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.userId, userId));
|
||||
|
||||
const existingCatMap = new Map(existingCats.map((c) => [c.name.toLowerCase(), c.id]));
|
||||
|
||||
// Create missing categories
|
||||
const newCategoryNames: string[] = [];
|
||||
for (const catName of categoryNames) {
|
||||
if (!existingCatMap.has(catName.toLowerCase())) {
|
||||
const [created] = await db
|
||||
.insert(categories)
|
||||
.values({ name: catName, userId })
|
||||
.returning();
|
||||
existingCatMap.set(catName.toLowerCase(), created.id);
|
||||
newCategoryNames.push(catName);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the "Uncategorized" category for items without a category
|
||||
let uncategorizedId = existingCatMap.get("uncategorized");
|
||||
if (!uncategorizedId) {
|
||||
const [unc] = await db
|
||||
.insert(categories)
|
||||
.values({ name: "Uncategorized", userId })
|
||||
.returning();
|
||||
uncategorizedId = unc.id;
|
||||
}
|
||||
|
||||
// Create user items linked to global items
|
||||
let itemsCreated = 0;
|
||||
for (const gi of selectedItems) {
|
||||
const catId = gi.category
|
||||
? existingCatMap.get(gi.category.toLowerCase()) ?? uncategorizedId
|
||||
: uncategorizedId;
|
||||
|
||||
await db.insert(items).values({
|
||||
name: gi.brand ? `${gi.brand} ${gi.model}` : gi.model,
|
||||
categoryId: catId,
|
||||
userId,
|
||||
weightGrams: gi.weightGrams,
|
||||
priceCents: gi.priceCents,
|
||||
imageFilename: gi.imageFilename,
|
||||
globalItemId: gi.id,
|
||||
});
|
||||
itemsCreated++;
|
||||
}
|
||||
|
||||
// Mark onboarding complete
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ userId, key: "onboardingComplete", value: "true" })
|
||||
.onConflictDoUpdate({
|
||||
target: [settings.userId, settings.key],
|
||||
set: { value: "true" },
|
||||
});
|
||||
|
||||
return { itemsCreated, categoriesCreated: newCategoryNames };
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "completeOnboarding" src/server/services/onboarding.service.ts && grep "onboardingComplete" src/server/services/onboarding.service.ts && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `src/server/services/onboarding.service.ts` exports `completeOnboarding` function
|
||||
- Accepts `db`, `userId`, `globalItemIds` parameters
|
||||
- Fetches global items, auto-creates missing user categories from global item category names
|
||||
- Creates user items with `globalItemId` link for each selected global item
|
||||
- Falls back to "Uncategorized" for items without a category
|
||||
- Sets `onboardingComplete` setting to "true" using upsert
|
||||
- Returns `{ itemsCreated, categoriesCreated }` summary
|
||||
- Handles empty `globalItemIds` by just marking complete (no items created)
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 5: Create onboarding route with Zod validation
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/server/index.ts
|
||||
- src/shared/schemas.ts
|
||||
- src/server/routes/settings.ts
|
||||
</read_first>
|
||||
<action>
|
||||
1. Add Zod schema to `src/shared/schemas.ts`:
|
||||
|
||||
```ts
|
||||
export const completeOnboardingSchema = z.object({
|
||||
globalItemIds: z.array(z.number().int().positive()).max(50),
|
||||
});
|
||||
```
|
||||
|
||||
2. Create `src/server/routes/onboarding.ts`:
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { completeOnboardingSchema } from "../../shared/schemas.ts";
|
||||
import { completeOnboarding } from "../services/onboarding.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any; userId?: number } };
|
||||
|
||||
const app = new Hono<Env>();
|
||||
|
||||
// POST /api/onboarding/complete
|
||||
app.post(
|
||||
"/complete",
|
||||
zValidator("json", completeOnboardingSchema),
|
||||
async (c) => {
|
||||
const database = c.get("db");
|
||||
const userId = c.get("userId")!;
|
||||
const { globalItemIds } = c.req.valid("json");
|
||||
|
||||
const result = await completeOnboarding(database, userId, globalItemIds);
|
||||
return c.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
3. Register route in `src/server/index.ts`:
|
||||
|
||||
Add after existing route registrations:
|
||||
```ts
|
||||
import onboardingRoutes from "./routes/onboarding.ts";
|
||||
// ...
|
||||
app.route("/api/onboarding", onboardingRoutes);
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "completeOnboardingSchema" src/shared/schemas.ts && grep "/api/onboarding" src/server/index.ts && grep "completeOnboarding" src/server/routes/onboarding.ts && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `completeOnboardingSchema` in schemas.ts validates `globalItemIds` as array of positive ints, max 50
|
||||
- `src/server/routes/onboarding.ts` exists with POST `/complete` endpoint
|
||||
- Endpoint uses `zValidator` for request validation
|
||||
- Route registered as `/api/onboarding` in server index.ts
|
||||
- Endpoint calls `completeOnboarding` service and returns result
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `bun run lint` passes without errors
|
||||
2. `bun test` passes (existing tests not broken)
|
||||
3. `GET /api/discovery/popular-items?tags=bikepacking` returns `{ items: [...] }` with ownerCount field
|
||||
4. `POST /api/onboarding/complete` with `{ globalItemIds: [] }` returns `{ itemsCreated: 0, categoriesCreated: [] }`
|
||||
5. `POST /api/onboarding/complete` with invalid body returns 400
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Shared hobby config with 6 hobbies and tag mappings
|
||||
- Popular items endpoint returns catalog items sorted by owner count
|
||||
- Onboarding completion endpoint batch-creates items with auto-categories
|
||||
- All endpoints have Zod validation
|
||||
- No existing tests broken
|
||||
</success_criteria>
|
||||
|
||||
<threat_model>
|
||||
| Threat | Severity | Mitigation |
|
||||
|--------|----------|------------|
|
||||
| Bulk item creation abuse via large globalItemIds array | Medium | Zod schema limits array to max 50 items; auth required |
|
||||
| Category injection via crafted global item category names | Low | Categories created from trusted catalog data, not direct user input; names are plain strings |
|
||||
| Duplicate item creation on repeated onboarding complete | Low | Endpoint is idempotent for settings but creates items each call; UI prevents re-triggering after onboardingComplete is set |
|
||||
| SQL injection via tag names in popular-items query | Low | drizzle-orm parameterizes all queries; inArray uses prepared statements |
|
||||
</threat_model>
|
||||
|
||||
<must_haves>
|
||||
- [ ] Hobby config with tag mappings shared between client and server
|
||||
- [ ] Popular items by tags endpoint with owner count ordering
|
||||
- [ ] Batch onboarding completion endpoint with auto-category creation
|
||||
- [ ] Zod validation on onboarding endpoint
|
||||
- [ ] All existing tests pass
|
||||
</must_haves>
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
phase: 30-onboarding-redesign
|
||||
plan: 01
|
||||
subsystem: api
|
||||
tags: [hono, drizzle, zod, discovery, onboarding]
|
||||
|
||||
requires:
|
||||
- phase: 28-profile-and-logto-integration
|
||||
provides: catalog infrastructure (globalItems, tags, globalItemTags tables)
|
||||
provides:
|
||||
- shared hobby-to-tag mapping config
|
||||
- popular items by tags discovery endpoint
|
||||
- batch onboarding completion endpoint with auto-category creation
|
||||
affects: [30-02, 30-03]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [hobby-tag mapping as shared config, batch item creation with auto-categories]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/shared/hobbyConfig.ts
|
||||
- src/server/services/onboarding.service.ts
|
||||
- src/server/routes/onboarding.ts
|
||||
modified:
|
||||
- src/server/services/discovery.service.ts
|
||||
- src/server/routes/discovery.ts
|
||||
- src/shared/schemas.ts
|
||||
- src/server/index.ts
|
||||
|
||||
key-decisions:
|
||||
- "Hobby-tag mapping as static shared config (no DB table) — extensible by editing hobbyConfig.ts"
|
||||
- "Popular items sorted by owner count using COUNT(DISTINCT items.id) via LEFT JOIN"
|
||||
- "Onboarding completion upserts settings using onConflictDoUpdate pattern"
|
||||
|
||||
patterns-established:
|
||||
- "Shared config in src/shared/ for client+server constants"
|
||||
- "Batch item creation with auto-category creation from catalog metadata"
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
duration: 8min
|
||||
completed: 2026-04-12
|
||||
---
|
||||
|
||||
# Plan 30-01: Backend Onboarding Infrastructure Summary
|
||||
|
||||
**Shared hobby config, popular-items-by-tags endpoint with owner count ordering, and batch onboarding completion service with auto-category creation**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 8 min
|
||||
- **Tasks:** 5
|
||||
- **Files modified:** 7
|
||||
|
||||
## Accomplishments
|
||||
- Created shared hobby configuration with 6 hobbies mapped to catalog tags
|
||||
- Added `getPopularItemsByTags` query to discovery service with owner count ordering
|
||||
- Added `GET /api/discovery/popular-items?tags=` endpoint with image URL enrichment
|
||||
- Created onboarding service that batch-creates user items from catalog selections with auto-generated categories
|
||||
- Created `POST /api/onboarding/complete` endpoint with Zod validation (max 50 items)
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Create shared hobby configuration** - `d37e64e` (feat)
|
||||
2. **Task 2: Add popular-items-by-tags query** - `2347d49` (feat)
|
||||
3. **Task 3: Add popular-items endpoint** - `d647080` (feat)
|
||||
4. **Task 4: Create onboarding service** - `9da4c84` (feat)
|
||||
5. **Task 5: Create onboarding route + register** - `5b35e60` (feat)
|
||||
|
||||
**Lint fix:** `9448571` (fix: import ordering)
|
||||
|
||||
## Files Created/Modified
|
||||
- `src/shared/hobbyConfig.ts` - Hobby definitions with tag mappings and getTagsForHobbies helper
|
||||
- `src/server/services/discovery.service.ts` - Added getPopularItemsByTags with owner count SQL
|
||||
- `src/server/routes/discovery.ts` - Added /popular-items GET endpoint
|
||||
- `src/server/services/onboarding.service.ts` - Batch item creation with auto-category logic
|
||||
- `src/server/routes/onboarding.ts` - POST /complete with Zod validation
|
||||
- `src/shared/schemas.ts` - Added completeOnboardingSchema
|
||||
- `src/server/index.ts` - Registered onboarding routes
|
||||
|
||||
## Decisions Made
|
||||
None - followed plan as specified.
|
||||
|
||||
## Deviations from Plan
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
- Biome lint flagged import ordering in discovery.service.ts and onboarding.ts — fixed in a follow-up commit.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Backend endpoints ready for frontend consumption in Plan 02
|
||||
- Hobby config importable from both client and server code
|
||||
|
||||
---
|
||||
*Phase: 30-onboarding-redesign*
|
||||
*Completed: 2026-04-12*
|
||||
@@ -0,0 +1,977 @@
|
||||
---
|
||||
phase: 30
|
||||
plan: 02
|
||||
type: frontend
|
||||
wave: 2
|
||||
depends_on: [01]
|
||||
files_modified:
|
||||
- src/client/components/onboarding/OnboardingFlow.tsx
|
||||
- src/client/components/onboarding/OnboardingWelcome.tsx
|
||||
- src/client/components/onboarding/OnboardingHobbyPicker.tsx
|
||||
- src/client/components/onboarding/OnboardingItemBrowser.tsx
|
||||
- src/client/components/onboarding/OnboardingReview.tsx
|
||||
- src/client/components/onboarding/OnboardingDone.tsx
|
||||
- src/client/components/onboarding/StepIndicator.tsx
|
||||
- src/client/components/onboarding/SelectableItemCard.tsx
|
||||
- src/client/components/onboarding/HobbyCard.tsx
|
||||
- src/client/hooks/useOnboarding.ts
|
||||
autonomous: true
|
||||
requirements: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the full-screen, catalog-driven onboarding flow UI with five steps: Welcome, Hobby Picker, Item Browser, Review, and Done. Includes hobby card selection, popular item grid with check/uncheck, review list with remove, and smooth CSS transitions between steps. All components follow the UI-SPEC design contract exactly.
|
||||
</objective>
|
||||
|
||||
<tasks>
|
||||
|
||||
### Task 1: Create onboarding hooks for data fetching and mutations
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/hooks/useGlobalItems.ts
|
||||
- src/client/hooks/useSettings.ts
|
||||
- src/client/lib/api.ts
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/client/hooks/useOnboarding.ts`:
|
||||
|
||||
```ts
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiGet, apiPost } from "../lib/api";
|
||||
|
||||
interface PopularItem {
|
||||
id: number;
|
||||
brand: string | null;
|
||||
model: string;
|
||||
category: string | null;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
imageFilename: string | null;
|
||||
imageUrl: string | null;
|
||||
description: string | null;
|
||||
ownerCount: number;
|
||||
}
|
||||
|
||||
/** Fetch popular catalog items for the given tags */
|
||||
export function usePopularItems(tags: string[]) {
|
||||
return useQuery({
|
||||
queryKey: ["popular-items", tags],
|
||||
queryFn: () =>
|
||||
apiGet<{ items: PopularItem[] }>(
|
||||
`/api/discovery/popular-items?tags=${tags.join(",")}&limit=24`,
|
||||
).then((res) => res.items),
|
||||
enabled: tags.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** Complete onboarding by batch-adding selected items */
|
||||
export function useCompleteOnboarding() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (globalItemIds: number[]) =>
|
||||
apiPost<{ itemsCreated: number; categoriesCreated: string[] }>(
|
||||
"/api/onboarding/complete",
|
||||
{ globalItemIds },
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "usePopularItems" src/client/hooks/useOnboarding.ts && grep "useCompleteOnboarding" src/client/hooks/useOnboarding.ts && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `usePopularItems` hook accepts `tags: string[]` and fetches from `/api/discovery/popular-items`
|
||||
- Query is disabled when tags array is empty (`enabled: tags.length > 0`)
|
||||
- `useCompleteOnboarding` mutation POSTs to `/api/onboarding/complete`
|
||||
- On success, invalidates `settings`, `items`, and `categories` query keys
|
||||
- Both hooks use `apiGet`/`apiPost` from `lib/api`
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 2: Create StepIndicator component
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/OnboardingWizard.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/client/components/onboarding/StepIndicator.tsx`:
|
||||
|
||||
```tsx
|
||||
interface StepIndicatorProps {
|
||||
progress: number; // 0 to 100
|
||||
}
|
||||
|
||||
export function StepIndicator({ progress }: StepIndicatorProps) {
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 h-1 bg-gray-100 z-50">
|
||||
<div
|
||||
className="h-1 bg-gray-700 transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "StepIndicator" src/client/components/onboarding/StepIndicator.tsx && grep "bg-gray-700" src/client/components/onboarding/StepIndicator.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `StepIndicator` component renders a fixed top bar with `h-1 bg-gray-100`
|
||||
- Progress fill uses `bg-gray-700` with `transition-all duration-500`
|
||||
- Width set via inline style `width: {progress}%`
|
||||
- Container has `z-50` for layering above content
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 3: Create HobbyCard component
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/lib/iconData.ts
|
||||
- src/shared/hobbyConfig.ts
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/client/components/onboarding/HobbyCard.tsx`:
|
||||
|
||||
```tsx
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
|
||||
interface HobbyCardProps {
|
||||
name: string;
|
||||
icon: string;
|
||||
descriptor: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function HobbyCard({ name, icon, descriptor, selected, onClick }: HobbyCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
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-50 border border-gray-200 hover:border-gray-300 hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<LucideIcon name={icon} size={32} className="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>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "HobbyCard" src/client/components/onboarding/HobbyCard.tsx && grep "ring-gray-700/20" src/client/components/onboarding/HobbyCard.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `HobbyCard` renders a 40x40 (w-40 h-40) button with rounded-2xl
|
||||
- Default state: `bg-gray-50 border border-gray-200`
|
||||
- Hover state: `border-gray-300 shadow-sm`
|
||||
- Selected state: `border-gray-700 ring-2 ring-gray-700/20 bg-white`
|
||||
- Shows `LucideIcon` at size 32, name text as `text-sm font-semibold`, descriptor as `text-xs text-gray-400`
|
||||
- Uses `p-5` internal padding (20px) per UI-SPEC exception
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 4: Create SelectableItemCard component
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/GlobalItemCard.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/client/components/onboarding/SelectableItemCard.tsx`:
|
||||
|
||||
```tsx
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
import { useFormatters } from "../../hooks/useFormatters";
|
||||
|
||||
interface SelectableItemCardProps {
|
||||
brand: string | null;
|
||||
model: string;
|
||||
imageUrl: string | null;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
ownerCount: number;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function SelectableItemCard({
|
||||
brand,
|
||||
model,
|
||||
imageUrl,
|
||||
weightGrams,
|
||||
priceCents,
|
||||
ownerCount,
|
||||
selected,
|
||||
onClick,
|
||||
}: SelectableItemCardProps) {
|
||||
const { formatWeight, formatPrice } = useFormatters();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`relative bg-white rounded-xl border text-left transition-all ${
|
||||
selected
|
||||
? "border-gray-700 ring-2 ring-gray-700/20"
|
||||
: "border-gray-100 hover:border-gray-200 hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{/* Selection indicator */}
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center ${
|
||||
selected
|
||||
? "bg-gray-700 border-gray-700"
|
||||
: "border-2 border-gray-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<LucideIcon name="check" size={14} className="text-white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="aspect-square bg-gray-50 rounded-t-xl overflow-hidden">
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={brand ? `${brand} ${model}` : model}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<LucideIcon name="package" size={32} className="text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3">
|
||||
{brand && (
|
||||
<div className="text-xs text-gray-400 truncate">{brand}</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-900 font-medium truncate">{model}</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-gray-400">
|
||||
{weightGrams != null && <span>{formatWeight(weightGrams)}</span>}
|
||||
{priceCents != null && <span>{formatPrice(priceCents)}</span>}
|
||||
</div>
|
||||
{ownerCount > 0 && (
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{ownerCount} {ownerCount === 1 ? "owner" : "owners"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "SelectableItemCard" src/client/components/onboarding/SelectableItemCard.tsx && grep "ring-gray-700/20" src/client/components/onboarding/SelectableItemCard.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `SelectableItemCard` renders card with `bg-white rounded-xl border border-gray-100`
|
||||
- Selected state: `border-gray-700 ring-2 ring-gray-700/20`
|
||||
- Selection indicator: absolute top-2 right-2, 24x24 circle (w-6 h-6)
|
||||
- Unselected circle: `border-2 border-gray-200 bg-white rounded-full`
|
||||
- Selected circle: `bg-gray-700` with white check icon at size 14
|
||||
- Shows image (or package fallback), brand, model, weight, price, owner count
|
||||
- Uses `useFormatters` hook for weight/price display
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 5: Create OnboardingWelcome step component
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/onboarding/StepIndicator.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/client/components/onboarding/OnboardingWelcome.tsx`:
|
||||
|
||||
```tsx
|
||||
interface OnboardingWelcomeProps {
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingWelcome({ onContinue }: OnboardingWelcomeProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-8">
|
||||
<div className="max-w-2xl text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Welcome to GearBox
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8 leading-relaxed">
|
||||
Tell us what you're into, and we'll help you set up your collection
|
||||
with gear that people actually use.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Let's go
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "Welcome to GearBox" src/client/components/onboarding/OnboardingWelcome.tsx && grep "Let's go" src/client/components/onboarding/OnboardingWelcome.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Heading: "Welcome to GearBox" in `text-3xl font-bold text-gray-900`
|
||||
- Body: exact copy from UI-SPEC copywriting contract
|
||||
- CTA button: "Let's go" with `bg-gray-700 hover:bg-gray-800`
|
||||
- Layout: `min-h-screen`, centered with `max-w-2xl`
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 6: Create OnboardingHobbyPicker step component
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/shared/hobbyConfig.ts
|
||||
- src/client/components/onboarding/HobbyCard.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/client/components/onboarding/OnboardingHobbyPicker.tsx`:
|
||||
|
||||
```tsx
|
||||
import { HOBBIES } from "../../../shared/hobbyConfig";
|
||||
import { HobbyCard } from "./HobbyCard";
|
||||
|
||||
interface OnboardingHobbyPickerProps {
|
||||
selectedHobbies: string[];
|
||||
onToggleHobby: (hobbyId: string) => void;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingHobbyPicker({
|
||||
selectedHobbies,
|
||||
onToggleHobby,
|
||||
onContinue,
|
||||
}: OnboardingHobbyPickerProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-8">
|
||||
<div className="max-w-2xl text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
What are you into?
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
Pick one or more — we'll show you popular gear for each.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4 mb-8">
|
||||
{HOBBIES.map((hobby) => (
|
||||
<HobbyCard
|
||||
key={hobby.id}
|
||||
name={hobby.name}
|
||||
icon={hobby.icon}
|
||||
descriptor={hobby.descriptor}
|
||||
selected={selectedHobbies.includes(hobby.id)}
|
||||
onClick={() => onToggleHobby(hobby.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
disabled={selectedHobbies.length === 0}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "OnboardingHobbyPicker" src/client/components/onboarding/OnboardingHobbyPicker.tsx && grep "What are you into" src/client/components/onboarding/OnboardingHobbyPicker.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Heading: "What are you into?" per UI-SPEC copy
|
||||
- Body: "Pick one or more — we'll show you popular gear for each."
|
||||
- Renders all 6 hobbies from `HOBBIES` config as `HobbyCard` components
|
||||
- Cards in `flex flex-wrap justify-center gap-4` layout
|
||||
- Continue button disabled when no hobbies selected (`disabled:opacity-50`)
|
||||
- `onToggleHobby` callback toggles hobby selection
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 7: Create OnboardingItemBrowser step component
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/hooks/useOnboarding.ts
|
||||
- src/client/components/onboarding/SelectableItemCard.tsx
|
||||
- src/shared/hobbyConfig.ts
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/client/components/onboarding/OnboardingItemBrowser.tsx`:
|
||||
|
||||
```tsx
|
||||
import { getTagsForHobbies } from "../../../shared/hobbyConfig";
|
||||
import { usePopularItems } from "../../hooks/useOnboarding";
|
||||
import { SelectableItemCard } from "./SelectableItemCard";
|
||||
|
||||
interface OnboardingItemBrowserProps {
|
||||
selectedHobbies: string[];
|
||||
selectedItemIds: Set<number>;
|
||||
onToggleItem: (itemId: number) => void;
|
||||
onContinue: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingItemBrowser({
|
||||
selectedHobbies,
|
||||
selectedItemIds,
|
||||
onToggleItem,
|
||||
onContinue,
|
||||
onSkip,
|
||||
}: OnboardingItemBrowserProps) {
|
||||
const tags = getTagsForHobbies(selectedHobbies);
|
||||
const { data: items, isLoading } = usePopularItems(tags);
|
||||
|
||||
const hasItems = items && items.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center min-h-screen px-8 py-16">
|
||||
<div className="max-w-5xl w-full text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Popular gear for {selectedHobbies.length === 1
|
||||
? selectedHobbies[0]
|
||||
: "your hobbies"}
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
Tap items you already own. We'll add them to your collection.
|
||||
</p>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-700 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !hasItems && (
|
||||
<div className="py-12 text-center">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No gear cataloged yet
|
||||
</h2>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
We're still building our catalog for this hobby. You can skip
|
||||
this step and add gear manually later.
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{hasItems && selectedItemIds.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Review {selectedItemIds.size} {selectedItemIds.size === 1 ? "item" : "items"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Skip this step
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "OnboardingItemBrowser" src/client/components/onboarding/OnboardingItemBrowser.tsx && grep "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4" src/client/components/onboarding/OnboardingItemBrowser.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Heading: "Popular gear for {hobby}" per UI-SPEC copy
|
||||
- Body: "Tap items you already own. We'll add them to your collection."
|
||||
- Grid: `grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4` per responsive spec
|
||||
- Max content width: `max-w-5xl` (1024px) for item grid per UI-SPEC
|
||||
- Loading state shows spinner
|
||||
- Empty state shows "No gear cataloged yet" heading and body per UI-SPEC copy
|
||||
- Selected items count shown on continue button: "Review N items"
|
||||
- "Skip this step" link always visible
|
||||
- Uses `usePopularItems` hook with tags from `getTagsForHobbies`
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 8: Create OnboardingReview step component
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/hooks/useOnboarding.ts
|
||||
- src/client/lib/iconData.ts
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/client/components/onboarding/OnboardingReview.tsx`:
|
||||
|
||||
```tsx
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
|
||||
interface ReviewItem {
|
||||
id: number;
|
||||
brand: string | null;
|
||||
model: string;
|
||||
imageUrl: string | null;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
interface OnboardingReviewProps {
|
||||
items: ReviewItem[];
|
||||
onRemoveItem: (itemId: number) => void;
|
||||
onConfirm: () => void;
|
||||
onSkip: () => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export function OnboardingReview({
|
||||
items,
|
||||
onRemoveItem,
|
||||
onConfirm,
|
||||
onSkip,
|
||||
isSubmitting,
|
||||
}: OnboardingReviewProps) {
|
||||
// Group by category
|
||||
const grouped = new Map<string, ReviewItem[]>();
|
||||
for (const item of items) {
|
||||
const cat = item.category || "Uncategorized";
|
||||
if (!grouped.has(cat)) grouped.set(cat, []);
|
||||
grouped.get(cat)!.push(item);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-8">
|
||||
<div className="max-w-2xl w-full text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Your starting collection
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
{items.length > 0
|
||||
? `${items.length} ${items.length === 1 ? "item" : "items"} ready to add`
|
||||
: "No items selected — you can always add gear later from the catalog."}
|
||||
</p>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="text-left mb-8">
|
||||
{[...grouped.entries()].map(([category, catItems]) => (
|
||||
<div key={category} className="mb-4">
|
||||
<div className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">
|
||||
{category}
|
||||
</div>
|
||||
{catItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 py-2 border-b border-gray-50"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg overflow-hidden bg-gray-50 shrink-0">
|
||||
{item.imageUrl ? (
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.model}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<LucideIcon
|
||||
name="package"
|
||||
size={16}
|
||||
className="text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-gray-900 truncate">
|
||||
{item.brand ? `${item.brand} ${item.model}` : item.model}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveItem(item.id)}
|
||||
className="text-gray-300 hover:text-red-500 transition-colors shrink-0"
|
||||
>
|
||||
<LucideIcon name="x" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{items.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isSubmitting}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isSubmitting ? "Adding..." : "Add to my collection"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Skip this step
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "OnboardingReview" src/client/components/onboarding/OnboardingReview.tsx && grep "Your starting collection" src/client/components/onboarding/OnboardingReview.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Heading: "Your starting collection" per UI-SPEC copy
|
||||
- Body: "{N} items ready to add" or "No items selected — you can always add gear later from the catalog." per UI-SPEC
|
||||
- Items grouped by category with `text-xs font-medium text-gray-400 uppercase tracking-wide` headings
|
||||
- Item rows: `flex items-center gap-3 py-2 border-b border-gray-50`
|
||||
- Image: `w-10 h-10 rounded-lg object-cover bg-gray-50`
|
||||
- Remove button: `text-gray-300 hover:text-red-500` with X icon size 16
|
||||
- CTA: "Add to my collection" per UI-SPEC, disabled during submission
|
||||
- "Skip this step" link available when items are selected
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 9: Create OnboardingDone step component
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/onboarding/OnboardingWelcome.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/client/components/onboarding/OnboardingDone.tsx`:
|
||||
|
||||
```tsx
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
|
||||
interface OnboardingDoneProps {
|
||||
itemsCreated: number;
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingDone({ itemsCreated, onFinish }: OnboardingDoneProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-8">
|
||||
<div className="max-w-2xl text-center">
|
||||
<div className="mb-6">
|
||||
<LucideIcon name="check-circle" size={48} className="text-gray-400 mx-auto" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
You're all set!
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
{itemsCreated > 0
|
||||
? "Your collection is ready. Browse the catalog anytime to discover more gear."
|
||||
: "Your collection is ready. Browse the catalog anytime to discover more gear."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFinish}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Start exploring
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "You're all set" src/client/components/onboarding/OnboardingDone.tsx && grep "Start exploring" src/client/components/onboarding/OnboardingDone.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Heading: "You're all set!" per UI-SPEC copy
|
||||
- Body: "Your collection is ready. Browse the catalog anytime to discover more gear." per UI-SPEC
|
||||
- CTA: "Start exploring" per UI-SPEC
|
||||
- Check-circle icon at size 48 in `text-gray-400`
|
||||
- Same layout as Welcome step: `min-h-screen`, centered, `max-w-2xl`
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 10: Create OnboardingFlow orchestrator component
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/OnboardingWizard.tsx
|
||||
- src/client/hooks/useOnboarding.ts
|
||||
- src/shared/hobbyConfig.ts
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/client/components/onboarding/OnboardingFlow.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { getTagsForHobbies } from "../../../shared/hobbyConfig";
|
||||
import { useCompleteOnboarding, usePopularItems } from "../../hooks/useOnboarding";
|
||||
import { useUpdateSetting } from "../../hooks/useSettings";
|
||||
import { OnboardingDone } from "./OnboardingDone";
|
||||
import { OnboardingHobbyPicker } from "./OnboardingHobbyPicker";
|
||||
import { OnboardingItemBrowser } from "./OnboardingItemBrowser";
|
||||
import { OnboardingReview } from "./OnboardingReview";
|
||||
import { OnboardingWelcome } from "./OnboardingWelcome";
|
||||
import { StepIndicator } from "./StepIndicator";
|
||||
|
||||
type Step = "welcome" | "hobby" | "browse" | "review" | "done";
|
||||
|
||||
const STEP_PROGRESS: Record<Step, number> = {
|
||||
welcome: 20,
|
||||
hobby: 40,
|
||||
browse: 60,
|
||||
review: 80,
|
||||
done: 100,
|
||||
};
|
||||
|
||||
interface OnboardingFlowProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||
const [step, setStep] = useState<Step>("welcome");
|
||||
const [transitioning, setTransitioning] = useState(false);
|
||||
const [selectedHobbies, setSelectedHobbies] = useState<string[]>([]);
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<number>>(new Set());
|
||||
const [itemsCreated, setItemsCreated] = useState(0);
|
||||
|
||||
const completeOnboarding = useCompleteOnboarding();
|
||||
const updateSetting = useUpdateSetting();
|
||||
|
||||
// Fetch items for review step data
|
||||
const tags = getTagsForHobbies(selectedHobbies);
|
||||
const { data: popularItems } = usePopularItems(tags);
|
||||
|
||||
const goToStep = useCallback((nextStep: Step) => {
|
||||
setTransitioning(true);
|
||||
setTimeout(() => {
|
||||
setStep(nextStep);
|
||||
setTransitioning(false);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const handleToggleHobby = useCallback((hobbyId: string) => {
|
||||
setSelectedHobbies((prev) =>
|
||||
prev.includes(hobbyId)
|
||||
? prev.filter((h) => h !== hobbyId)
|
||||
: [...prev, hobbyId],
|
||||
);
|
||||
// Reset item selections when hobbies change
|
||||
setSelectedItemIds(new Set());
|
||||
}, []);
|
||||
|
||||
const handleToggleItem = useCallback((itemId: number) => {
|
||||
setSelectedItemIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(itemId)) next.delete(itemId);
|
||||
else next.add(itemId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRemoveItem = useCallback((itemId: number) => {
|
||||
setSelectedItemIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
const ids = [...selectedItemIds];
|
||||
completeOnboarding.mutate(ids, {
|
||||
onSuccess: (result) => {
|
||||
setItemsCreated(result.itemsCreated);
|
||||
goToStep("done");
|
||||
},
|
||||
});
|
||||
}, [selectedItemIds, completeOnboarding, goToStep]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
updateSetting.mutate(
|
||||
{ key: "onboardingComplete", value: "true" },
|
||||
{ onSuccess: onComplete },
|
||||
);
|
||||
}, [updateSetting, onComplete]);
|
||||
|
||||
const handleSkipBrowse = useCallback(() => {
|
||||
// Skip browse and review — just mark complete
|
||||
updateSetting.mutate(
|
||||
{ key: "onboardingComplete", value: "true" },
|
||||
{ onSuccess: onComplete },
|
||||
);
|
||||
}, [updateSetting, onComplete]);
|
||||
|
||||
// Build review items from selected IDs
|
||||
const reviewItems = (popularItems || [])
|
||||
.filter((item) => selectedItemIds.has(item.id))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
brand: item.brand,
|
||||
model: item.model,
|
||||
imageUrl: item.imageUrl,
|
||||
category: item.category,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white overflow-y-auto">
|
||||
<StepIndicator progress={STEP_PROGRESS[step]} />
|
||||
|
||||
<div
|
||||
className={`transition-all duration-300 ${
|
||||
transitioning
|
||||
? "opacity-0 -translate-y-4"
|
||||
: "opacity-100 translate-y-0"
|
||||
}`}
|
||||
>
|
||||
{step === "welcome" && (
|
||||
<OnboardingWelcome onContinue={() => goToStep("hobby")} />
|
||||
)}
|
||||
|
||||
{step === "hobby" && (
|
||||
<OnboardingHobbyPicker
|
||||
selectedHobbies={selectedHobbies}
|
||||
onToggleHobby={handleToggleHobby}
|
||||
onContinue={() => goToStep("browse")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "browse" && (
|
||||
<OnboardingItemBrowser
|
||||
selectedHobbies={selectedHobbies}
|
||||
selectedItemIds={selectedItemIds}
|
||||
onToggleItem={handleToggleItem}
|
||||
onContinue={() => goToStep("review")}
|
||||
onSkip={handleSkipBrowse}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<OnboardingReview
|
||||
items={reviewItems}
|
||||
onRemoveItem={handleRemoveItem}
|
||||
onConfirm={handleConfirm}
|
||||
onSkip={handleSkipBrowse}
|
||||
isSubmitting={completeOnboarding.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "done" && (
|
||||
<OnboardingDone
|
||||
itemsCreated={itemsCreated}
|
||||
onFinish={onComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "OnboardingFlow" src/client/components/onboarding/OnboardingFlow.tsx && grep "transitioning" src/client/components/onboarding/OnboardingFlow.tsx && grep "StepIndicator" src/client/components/onboarding/OnboardingFlow.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `OnboardingFlow` manages 5 steps: welcome, hobby, browse, review, done
|
||||
- Full-screen overlay: `fixed inset-0 z-50 bg-white overflow-y-auto`
|
||||
- Step transitions: opacity-0/-translate-y-4 to opacity-100/translate-y-0 with 200ms exit + 300ms enter
|
||||
- StepIndicator shows progress: welcome=20%, hobby=40%, browse=60%, review=80%, done=100%
|
||||
- Hobby selection resets item selections when changed
|
||||
- Review step gets items from popularItems filtered by selectedItemIds
|
||||
- Confirm calls `useCompleteOnboarding` mutation, then transitions to done step
|
||||
- Skip calls `useUpdateSetting` to set onboardingComplete and triggers onComplete
|
||||
- `onComplete` prop called on final "Start exploring" click and all skip paths
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `bun run lint` passes
|
||||
2. `bun test` passes (existing tests not broken)
|
||||
3. All onboarding components exist in `src/client/components/onboarding/`
|
||||
4. `OnboardingFlow` renders full-screen overlay with step transitions
|
||||
5. HobbyCard has correct selected/unselected visual states per UI-SPEC
|
||||
6. SelectableItemCard has checkmark overlay per UI-SPEC
|
||||
7. ReviewList groups items by category with correct styling
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All 10 components created in src/client/components/onboarding/
|
||||
- Hooks for popular items fetching and onboarding completion
|
||||
- Full-screen flow with CSS step transitions
|
||||
- Copy matches UI-SPEC copywriting contract exactly
|
||||
- Visual states match UI-SPEC color and spacing specs
|
||||
- Responsive grid: 2/3/4 columns per breakpoint
|
||||
</success_criteria>
|
||||
|
||||
<threat_model>
|
||||
| Threat | Severity | Mitigation |
|
||||
|--------|----------|------------|
|
||||
| XSS via catalog item model/brand names | Low | React auto-escapes JSX text content; no dangerouslySetInnerHTML used |
|
||||
| Stale popular items cache showing removed items | Low | React Query default staleTime; items fetched fresh on hobby change |
|
||||
| UI state manipulation via browser devtools | Low | Server-side validation on /api/onboarding/complete; UI state is convenience only |
|
||||
</threat_model>
|
||||
|
||||
<must_haves>
|
||||
- [ ] Full-screen onboarding flow with 5 steps
|
||||
- [ ] Hobby picker with card-based selection (multi-select)
|
||||
- [ ] Item browser with selectable item grid
|
||||
- [ ] Review screen with grouped items and remove
|
||||
- [ ] CSS step transitions (no framer-motion)
|
||||
- [ ] Copy matches UI-SPEC exactly
|
||||
</must_haves>
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
phase: 30-onboarding-redesign
|
||||
plan: 02
|
||||
subsystem: ui
|
||||
tags: [react, tailwind, tanstack-query, onboarding, lucide]
|
||||
|
||||
requires:
|
||||
- phase: 30-onboarding-redesign
|
||||
provides: backend endpoints (Plan 01 - popular items, onboarding complete)
|
||||
provides:
|
||||
- full-screen 5-step onboarding flow UI
|
||||
- hobby card picker component
|
||||
- selectable item card with checkmark overlay
|
||||
- review list grouped by category
|
||||
- CSS step transitions
|
||||
affects: [30-03]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [full-screen overlay with CSS step transitions, shared hobby config import from @/shared]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/client/components/onboarding/OnboardingFlow.tsx
|
||||
- src/client/components/onboarding/OnboardingWelcome.tsx
|
||||
- src/client/components/onboarding/OnboardingHobbyPicker.tsx
|
||||
- src/client/components/onboarding/OnboardingItemBrowser.tsx
|
||||
- src/client/components/onboarding/OnboardingReview.tsx
|
||||
- src/client/components/onboarding/OnboardingDone.tsx
|
||||
- src/client/components/onboarding/StepIndicator.tsx
|
||||
- src/client/components/onboarding/SelectableItemCard.tsx
|
||||
- src/client/components/onboarding/HobbyCard.tsx
|
||||
- src/client/hooks/useOnboarding.ts
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "CSS transitions only — no framer-motion dependency"
|
||||
- "Prefixed unused itemsCreated param as _itemsCreated to satisfy lint"
|
||||
|
||||
patterns-established:
|
||||
- "Full-screen overlay pattern: fixed inset-0 z-50 bg-white overflow-y-auto"
|
||||
- "Step transition pattern: opacity + translate-y with setTimeout for exit animation"
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
duration: 10min
|
||||
completed: 2026-04-12
|
||||
---
|
||||
|
||||
# Plan 30-02: Full-Screen Onboarding Flow UI Summary
|
||||
|
||||
**5-step catalog-driven onboarding with hobby cards, selectable item grid, review list, and CSS step transitions following UI-SPEC design contract**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Tasks:** 10
|
||||
- **Files created:** 10
|
||||
|
||||
## Accomplishments
|
||||
- Created useOnboarding hooks (usePopularItems, useCompleteOnboarding)
|
||||
- Built StepIndicator progress bar component
|
||||
- Built HobbyCard with selected/unselected visual states per UI-SPEC
|
||||
- Built SelectableItemCard with checkmark overlay per UI-SPEC
|
||||
- Built OnboardingWelcome, OnboardingHobbyPicker, OnboardingItemBrowser, OnboardingReview, OnboardingDone step components
|
||||
- Built OnboardingFlow orchestrator with step management and CSS transitions
|
||||
- All copy matches UI-SPEC copywriting contract exactly
|
||||
- Responsive grid: 2/3/4 columns per breakpoint
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Tasks 1-10: Full onboarding UI** - `5c18a3c` (feat)
|
||||
|
||||
**Lint fix:** `0db8771` (fix: biome formatting)
|
||||
|
||||
## Deviations from Plan
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
- Biome formatter required different line breaking for destructured props and ternary expressions — fixed in follow-up commit.
|
||||
|
||||
## User Setup Required
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
- OnboardingFlow component ready for integration in __root.tsx (Plan 03)
|
||||
|
||||
---
|
||||
*Phase: 30-onboarding-redesign*
|
||||
*Completed: 2026-04-12*
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
phase: 30
|
||||
plan: 03
|
||||
type: integration
|
||||
wave: 2
|
||||
depends_on: [01, 02]
|
||||
files_modified:
|
||||
- src/client/routes/__root.tsx
|
||||
- src/client/components/OnboardingWizard.tsx
|
||||
autonomous: true
|
||||
requirements: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Replace the old OnboardingWizard with the new OnboardingFlow in the root route trigger, ensure the onboarding flow triggers correctly on first login, and remove the old wizard component file.
|
||||
</objective>
|
||||
|
||||
<tasks>
|
||||
|
||||
### Task 1: Replace OnboardingWizard with OnboardingFlow in root route
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/routes/__root.tsx
|
||||
- src/client/components/OnboardingWizard.tsx
|
||||
- src/client/components/onboarding/OnboardingFlow.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
Update `src/client/routes/__root.tsx`:
|
||||
|
||||
1. Replace the import:
|
||||
- Remove: `import { OnboardingWizard } from "../components/OnboardingWizard";`
|
||||
- Add: `import { OnboardingFlow } from "../components/onboarding/OnboardingFlow";`
|
||||
|
||||
2. Find the onboarding rendering logic (around lines 193+). The current code conditionally renders `<OnboardingWizard onComplete={...} />`. Replace with `<OnboardingFlow onComplete={...} />`.
|
||||
|
||||
The `onComplete` callback should:
|
||||
- Dismiss the onboarding overlay (same behavior as current wizard)
|
||||
- The OnboardingFlow already handles setting `onboardingComplete` via its internal hooks
|
||||
|
||||
The trigger logic stays the same: show onboarding when `onboardingComplete !== "true"` and user is authenticated.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "OnboardingFlow" src/client/routes/__root.tsx && ! grep "OnboardingWizard" src/client/routes/__root.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `__root.tsx` imports `OnboardingFlow` from `../components/onboarding/OnboardingFlow`
|
||||
- No import of `OnboardingWizard` remains in `__root.tsx`
|
||||
- `<OnboardingFlow onComplete={...} />` replaces `<OnboardingWizard onComplete={...} />`
|
||||
- Onboarding trigger condition unchanged: authenticated + onboardingComplete !== "true"
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 2: Remove old OnboardingWizard component
|
||||
<task type="command">
|
||||
<read_first>
|
||||
- src/client/components/OnboardingWizard.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
Delete the old onboarding wizard file:
|
||||
|
||||
```bash
|
||||
rm src/client/components/OnboardingWizard.tsx
|
||||
```
|
||||
|
||||
Then verify no other files import it:
|
||||
|
||||
```bash
|
||||
grep -r "OnboardingWizard" src/ --include="*.ts" --include="*.tsx"
|
||||
```
|
||||
|
||||
If any references remain, update them to use OnboardingFlow or remove them.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test ! -f src/client/components/OnboardingWizard.tsx && ! grep -r "OnboardingWizard" src/ --include="*.ts" --include="*.tsx" 2>/dev/null && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `src/client/components/OnboardingWizard.tsx` file no longer exists
|
||||
- No references to `OnboardingWizard` in any `.ts` or `.tsx` file under `src/`
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 3: Verify onboarding trigger logic
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/routes/__root.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
Verify that the onboarding trigger in `__root.tsx` works correctly with the new flow:
|
||||
|
||||
1. The condition for showing onboarding should check:
|
||||
- User is authenticated (session exists)
|
||||
- `onboardingComplete` setting is not `"true"`
|
||||
- Onboarding has not been dismissed in this session
|
||||
|
||||
2. The `onComplete` callback should:
|
||||
- Set local state to dismiss the onboarding overlay
|
||||
- The OnboardingFlow component handles the server-side setting update internally
|
||||
|
||||
3. Ensure the OnboardingFlow receives `onComplete` prop that triggers the root route to stop rendering the overlay.
|
||||
|
||||
No changes may be needed if the existing trigger logic already works with the new component signature (both old and new use `onComplete: () => void`). Verify and adjust only if needed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -A5 "onboardingComplete" src/client/routes/__root.tsx | grep -q "OnboardingFlow" && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Onboarding renders when authenticated AND onboardingComplete !== "true"
|
||||
- OnboardingFlow receives `onComplete` callback
|
||||
- After completion, OnboardingFlow no longer renders
|
||||
- Page behind onboarding is accessible after completion (no stuck overlay)
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `bun run lint` passes
|
||||
2. `bun test` passes
|
||||
3. `bun run build` succeeds (no dead imports or missing modules)
|
||||
4. New user (onboardingComplete not set) sees full-screen OnboardingFlow on login
|
||||
5. After completing onboarding, OnboardingFlow is dismissed and collection is shown
|
||||
6. Existing user (onboardingComplete = "true") does NOT see onboarding
|
||||
7. Old OnboardingWizard.tsx file is gone
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Old OnboardingWizard replaced with new OnboardingFlow
|
||||
- Trigger logic preserved — shows for new users, hidden for existing
|
||||
- Build succeeds with no dead imports
|
||||
- Clean removal of old component file
|
||||
</success_criteria>
|
||||
|
||||
<threat_model>
|
||||
| Threat | Severity | Mitigation |
|
||||
|--------|----------|------------|
|
||||
| Onboarding overlay stuck on screen (JS error) | Medium | onComplete callback triggers local state dismissal; setting update is secondary |
|
||||
| Old wizard references causing build failure | Low | grep verification ensures no stale imports remain |
|
||||
</threat_model>
|
||||
|
||||
<must_haves>
|
||||
- [ ] OnboardingWizard replaced by OnboardingFlow in __root.tsx
|
||||
- [ ] Old OnboardingWizard.tsx deleted with no stale references
|
||||
- [ ] Onboarding triggers correctly for new users
|
||||
- [ ] Build succeeds
|
||||
</must_haves>
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
phase: 30-onboarding-redesign
|
||||
plan: 03
|
||||
subsystem: ui
|
||||
tags: [react, tanstack-router, integration]
|
||||
|
||||
requires:
|
||||
- phase: 30-onboarding-redesign
|
||||
provides: OnboardingFlow component (Plan 02)
|
||||
provides:
|
||||
- OnboardingFlow integrated into root route
|
||||
- Old OnboardingWizard removed
|
||||
affects: []
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: []
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- src/client/routes/__root.tsx
|
||||
|
||||
key-decisions:
|
||||
- "Same onComplete callback pattern preserved from old wizard"
|
||||
|
||||
patterns-established: []
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
duration: 3min
|
||||
completed: 2026-04-12
|
||||
---
|
||||
|
||||
# Plan 30-03: Integration Summary
|
||||
|
||||
**Replaced old OnboardingWizard with new OnboardingFlow in root route, deleted old component, verified build and no stale references**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 1 modified, 1 deleted
|
||||
|
||||
## Accomplishments
|
||||
- Replaced OnboardingWizard import with OnboardingFlow in __root.tsx
|
||||
- Preserved onboarding trigger logic (authenticated + onboardingComplete !== "true")
|
||||
- Deleted old OnboardingWizard.tsx (319 lines removed)
|
||||
- Verified no stale references remain
|
||||
- Build succeeds with no dead imports
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Tasks 1-3: Integration and cleanup** - `115766c` (feat)
|
||||
|
||||
## Deviations from Plan
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Phase 30 implementation complete — ready for verification
|
||||
|
||||
---
|
||||
*Phase: 30-onboarding-redesign*
|
||||
*Completed: 2026-04-12*
|
||||
@@ -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)
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
phase: 30
|
||||
status: passed
|
||||
verified: 2026-04-12
|
||||
---
|
||||
|
||||
# Phase 30: Onboarding Redesign — Verification
|
||||
|
||||
## Automated Checks
|
||||
|
||||
| Check | Status | Detail |
|
||||
|-------|--------|--------|
|
||||
| Lint (biome) | PASS | 198 files checked, no errors |
|
||||
| Build (vite) | PASS | Built in 770ms, no errors |
|
||||
| Key files exist | PASS | All 14 new files present |
|
||||
| Old wizard removed | PASS | OnboardingWizard.tsx deleted |
|
||||
| No stale refs | PASS | No OnboardingWizard imports remain |
|
||||
| Schema drift | PASS | No schema changes in this phase |
|
||||
|
||||
## Must-Haves Verification
|
||||
|
||||
### Plan 01: Backend
|
||||
- [x] Shared hobby config with 6 hobbies and tag mappings (`src/shared/hobbyConfig.ts`)
|
||||
- [x] Popular items by tags endpoint with owner count ordering (`GET /api/discovery/popular-items`)
|
||||
- [x] Batch onboarding completion endpoint with auto-category creation (`POST /api/onboarding/complete`)
|
||||
- [x] Zod validation on onboarding endpoint (`completeOnboardingSchema`)
|
||||
- [x] Existing tests unaffected (311 pre-existing failures, 0 new)
|
||||
|
||||
### Plan 02: Frontend
|
||||
- [x] Full-screen onboarding flow with 5 steps
|
||||
- [x] Hobby picker with card-based selection (multi-select)
|
||||
- [x] Item browser with selectable item grid
|
||||
- [x] Review screen with grouped items and remove
|
||||
- [x] CSS step transitions (no framer-motion)
|
||||
- [x] Copy matches UI-SPEC exactly
|
||||
|
||||
### Plan 03: Integration
|
||||
- [x] OnboardingWizard replaced by OnboardingFlow in __root.tsx
|
||||
- [x] Old OnboardingWizard.tsx deleted with no stale references
|
||||
- [x] Onboarding triggers correctly for new users
|
||||
- [x] Build succeeds
|
||||
|
||||
## Decision Coverage (D-01 to D-18)
|
||||
|
||||
| Decision | Status | Implementation |
|
||||
|----------|--------|---------------|
|
||||
| D-01 Flow structure | PASS | Welcome > Hobby > Browse > Review > Done |
|
||||
| D-02 Display name not in onboarding | PASS | Not included (correct) |
|
||||
| D-03 Profile pic not in onboarding | PASS | Not included (correct) |
|
||||
| D-04 Hobby selection is key step | PASS | OnboardingHobbyPicker with visual cards |
|
||||
| D-05 Categories auto-created | PASS | onboarding.service.ts auto-creates from global item categories |
|
||||
| D-06 Card-based hobby picker | PASS | HobbyCard with icons, 40x40 cards |
|
||||
| D-07 Hobbies map to tags | PASS | hobbyConfig.ts HOBBIES array with tags |
|
||||
| D-08 Multi-hobby selection | PASS | selectedHobbies array, toggle logic |
|
||||
| D-09 Popular items browsable grid | PASS | OnboardingItemBrowser with responsive grid |
|
||||
| D-10 Popular by owner count | PASS | SQL COUNT(DISTINCT items.id) ordering |
|
||||
| D-11 Check items batch selection | PASS | SelectableItemCard with checkmark overlay |
|
||||
| D-12 Review before commit | PASS | OnboardingReview with grouped items |
|
||||
| D-13 Full-screen experience | PASS | fixed inset-0 z-50 bg-white |
|
||||
| D-14 Replace centered modal | PASS | Old wizard deleted, new flow is full-screen |
|
||||
| D-15 Smooth transitions | PASS | CSS opacity + translate-y transitions |
|
||||
| D-16 Triggers on first login | PASS | showWizard condition preserved |
|
||||
| D-17 Hobby selection required | PASS | Continue button disabled when empty |
|
||||
| D-18 Other steps skippable | PASS | Skip links on browse and review steps |
|
||||
|
||||
## Human Verification Needed
|
||||
|
||||
| Item | Description |
|
||||
|------|-------------|
|
||||
| Visual polish | Full-screen steps with generous spacing and modern feel |
|
||||
| Step transitions | Smooth fade + slide between steps |
|
||||
| Hobby card design | Cards match Notion/Linear style |
|
||||
| Responsive layout | Item grid adjusts to 2/3/4 columns |
|
||||
|
||||
## Verification Complete
|
||||
|
||||
Phase 30 passes all automated verification. Human visual testing recommended for polish items.
|
||||
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]
|
||||
@@ -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,133 +0,0 @@
|
||||
CREATE TABLE "api_keys" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"key_hash" text NOT NULL,
|
||||
"key_prefix" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "categories" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"icon" text DEFAULT 'package' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "categories_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "items" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"weight_grams" double precision,
|
||||
"price_cents" integer,
|
||||
"category_id" integer NOT NULL,
|
||||
"notes" text,
|
||||
"product_url" text,
|
||||
"image_filename" text,
|
||||
"image_source_url" text,
|
||||
"quantity" integer DEFAULT 1 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oauth_clients" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"client_name" text,
|
||||
"redirect_uris" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "oauth_clients_client_id_unique" UNIQUE("client_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oauth_codes" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"code_challenge" text NOT NULL,
|
||||
"code_challenge_method" text DEFAULT 'S256' NOT NULL,
|
||||
"redirect_uri" text NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"used" boolean DEFAULT false NOT NULL,
|
||||
CONSTRAINT "oauth_codes_code_unique" UNIQUE("code")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oauth_tokens" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"access_token_hash" text NOT NULL,
|
||||
"refresh_token_hash" text NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"refresh_expires_at" timestamp NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "oauth_tokens_access_token_hash_unique" UNIQUE("access_token_hash"),
|
||||
CONSTRAINT "oauth_tokens_refresh_token_hash_unique" UNIQUE("refresh_token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sessions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"expires_at" timestamp NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "settings" (
|
||||
"key" text PRIMARY KEY NOT NULL,
|
||||
"value" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "setup_items" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"setup_id" integer NOT NULL,
|
||||
"item_id" integer NOT NULL,
|
||||
"classification" text DEFAULT 'base' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "setups" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "thread_candidates" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"thread_id" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"weight_grams" double precision,
|
||||
"price_cents" integer,
|
||||
"category_id" integer NOT NULL,
|
||||
"notes" text,
|
||||
"product_url" text,
|
||||
"image_filename" text,
|
||||
"image_source_url" text,
|
||||
"status" text DEFAULT 'researching' NOT NULL,
|
||||
"pros" text,
|
||||
"cons" text,
|
||||
"sort_order" double precision DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "threads" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"resolved_candidate_id" integer,
|
||||
"category_id" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"username" text NOT NULL,
|
||||
"password_hash" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_username_unique" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "items" ADD CONSTRAINT "items_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "setup_items" ADD CONSTRAINT "setup_items_setup_id_setups_id_fk" FOREIGN KEY ("setup_id") REFERENCES "public"."setups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "setup_items" ADD CONSTRAINT "setup_items_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_thread_id_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "threads" ADD CONSTRAINT "threads_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;
|
||||
@@ -1,12 +1,3 @@
|
||||
CREATE TABLE "global_item_tags" (
|
||||
"global_item_id" integer NOT NULL,
|
||||
"tag_id" integer NOT NULL,
|
||||
CONSTRAINT "global_item_tags_global_item_id_tag_id_pk" PRIMARY KEY("global_item_id","tag_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "global_items" ADD COLUMN "source_url" text;--> statement-breakpoint
|
||||
ALTER TABLE "global_items" ADD COLUMN "image_credit" text;--> statement-breakpoint
|
||||
ALTER TABLE "global_items" ADD COLUMN "image_source_url" text;--> statement-breakpoint
|
||||
ALTER TABLE "global_items" ADD COLUMN "dominant_color" text;--> statement-breakpoint
|
||||
ALTER TABLE "global_items" ADD COLUMN "crop_zoom" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "global_items" ADD COLUMN "crop_x" double precision;--> statement-breakpoint
|
||||
@@ -15,14 +6,7 @@ ALTER TABLE "items" ADD COLUMN "dominant_color" text;--> statement-breakpoint
|
||||
ALTER TABLE "items" ADD COLUMN "crop_zoom" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "items" ADD COLUMN "crop_x" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "items" ADD COLUMN "crop_y" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "oauth_codes" ADD COLUMN "user_id" integer NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "thread_candidates" ADD COLUMN "dominant_color" text;--> statement-breakpoint
|
||||
ALTER TABLE "thread_candidates" ADD COLUMN "crop_zoom" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "thread_candidates" ADD COLUMN "crop_x" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "thread_candidates" ADD COLUMN "crop_y" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "global_item_tags" ADD CONSTRAINT "global_item_tags_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "global_item_tags" ADD CONSTRAINT "global_item_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "items" ADD CONSTRAINT "items_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oauth_codes" ADD CONSTRAINT "oauth_codes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "global_items" ADD CONSTRAINT "global_items_brand_model_unique" UNIQUE("brand","model");
|
||||
ALTER TABLE "thread_candidates" ADD COLUMN "crop_y" double precision;
|
||||
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>
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useCreateCategory } from "../hooks/useCategories";
|
||||
import { useCreateItem } from "../hooks/useItems";
|
||||
import { useUpdateSetting } from "../hooks/useSettings";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { IconPicker } from "./IconPicker";
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
// Step 2 state
|
||||
const [categoryName, setCategoryName] = useState("");
|
||||
const [categoryIcon, setCategoryIcon] = useState("");
|
||||
const [categoryError, setCategoryError] = useState("");
|
||||
const [createdCategoryId, setCreatedCategoryId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Step 3 state
|
||||
const [itemName, setItemName] = useState("");
|
||||
const [itemWeight, setItemWeight] = useState("");
|
||||
const [itemPrice, setItemPrice] = useState("");
|
||||
const [itemError, setItemError] = useState("");
|
||||
|
||||
const createCategory = useCreateCategory();
|
||||
const createItem = useCreateItem();
|
||||
const updateSetting = useUpdateSetting();
|
||||
|
||||
function handleSkip() {
|
||||
updateSetting.mutate(
|
||||
{ key: "onboardingComplete", value: "true" },
|
||||
{ onSuccess: onComplete },
|
||||
);
|
||||
}
|
||||
|
||||
function handleCreateCategory() {
|
||||
const name = categoryName.trim();
|
||||
if (!name) {
|
||||
setCategoryError("Please enter a category name");
|
||||
return;
|
||||
}
|
||||
setCategoryError("");
|
||||
createCategory.mutate(
|
||||
{ name, icon: categoryIcon.trim() || undefined },
|
||||
{
|
||||
onSuccess: (created) => {
|
||||
setCreatedCategoryId(created.id);
|
||||
setStep(3);
|
||||
},
|
||||
onError: (err) => {
|
||||
setCategoryError(err.message || "Failed to create category");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleCreateItem() {
|
||||
const name = itemName.trim();
|
||||
if (!name) {
|
||||
setItemError("Please enter an item name");
|
||||
return;
|
||||
}
|
||||
if (!createdCategoryId) return;
|
||||
|
||||
setItemError("");
|
||||
const payload: any = {
|
||||
name,
|
||||
categoryId: createdCategoryId,
|
||||
};
|
||||
if (itemWeight) payload.weightGrams = Number(itemWeight);
|
||||
if (itemPrice) payload.priceCents = Math.round(Number(itemPrice) * 100);
|
||||
|
||||
createItem.mutate(payload, {
|
||||
onSuccess: () => setStep(4),
|
||||
onError: (err) => {
|
||||
setItemError(err.message || "Failed to add item");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleDone() {
|
||||
updateSetting.mutate(
|
||||
{ key: "onboardingComplete", value: "true" },
|
||||
{ onSuccess: onComplete },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" />
|
||||
|
||||
{/* Card */}
|
||||
<div className="relative z-10 w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-8">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
s <= Math.min(step, 3) ? "bg-gray-700 w-8" : "bg-gray-200 w-6"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Welcome */}
|
||||
{step === 1 && (
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-2">
|
||||
Welcome to GearBox!
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-8 leading-relaxed">
|
||||
Track your gear, compare weights, and plan smarter purchases.
|
||||
Let's set up your first category and item.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(2)}
|
||||
className="w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Get Started
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="mt-3 text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Skip setup
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Create category */}
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-1">
|
||||
Create a category
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Categories help you organize your gear (e.g. Shelter, Cooking,
|
||||
Clothing).
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="onboard-cat-name"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Category name *
|
||||
</label>
|
||||
<input
|
||||
id="onboard-cat-name"
|
||||
type="text"
|
||||
value={categoryName}
|
||||
onChange={(e) => setCategoryName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||
placeholder="e.g. Shelter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Icon (optional)
|
||||
</label>
|
||||
<IconPicker
|
||||
value={categoryIcon}
|
||||
onChange={setCategoryIcon}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{categoryError && (
|
||||
<p className="text-xs text-red-500">{categoryError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateCategory}
|
||||
disabled={createCategory.isPending}
|
||||
className="mt-6 w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{createCategory.isPending ? "Creating..." : "Create Category"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Skip setup
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Add item */}
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-1">
|
||||
Add your first item
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Add a piece of gear to your collection.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="onboard-item-name"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Item name *
|
||||
</label>
|
||||
<input
|
||||
id="onboard-item-name"
|
||||
type="text"
|
||||
value={itemName}
|
||||
onChange={(e) => setItemName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||
placeholder="e.g. Big Agnes Copper Spur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="onboard-item-weight"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Weight (g)
|
||||
</label>
|
||||
<input
|
||||
id="onboard-item-weight"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={itemWeight}
|
||||
onChange={(e) => setItemWeight(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||
placeholder="e.g. 1200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="onboard-item-price"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Price ($)
|
||||
</label>
|
||||
<input
|
||||
id="onboard-item-price"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={itemPrice}
|
||||
onChange={(e) => setItemPrice(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||
placeholder="e.g. 349.99"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{itemError && <p className="text-xs text-red-500">{itemError}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateItem}
|
||||
disabled={createItem.isPending}
|
||||
className="mt-6 w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{createItem.isPending ? "Adding..." : "Add Item"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Skip setup
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Done */}
|
||||
{step === 4 && (
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<LucideIcon
|
||||
name="party-popper"
|
||||
size={48}
|
||||
className="text-gray-400 mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
You're all set!
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-8">
|
||||
Your first item has been added. You can now browse your
|
||||
collection, add more gear, and track your setup.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDone}
|
||||
disabled={updateSetting.isPending}
|
||||
className="w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{updateSetting.isPending ? "Finishing..." : "Done"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 { 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">
|
||||
|
||||
47
src/client/components/onboarding/HobbyCard.tsx
Normal file
47
src/client/components/onboarding/HobbyCard.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
|
||||
interface HobbyCardProps {
|
||||
name: string;
|
||||
icon: string;
|
||||
descriptor: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function HobbyCard({
|
||||
name,
|
||||
icon,
|
||||
descriptor,
|
||||
selected,
|
||||
onClick,
|
||||
}: HobbyCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
? "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={selected ? "text-white" : "text-gray-700"}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
39
src/client/components/onboarding/OnboardingDone.tsx
Normal file
39
src/client/components/onboarding/OnboardingDone.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
|
||||
interface OnboardingDoneProps {
|
||||
itemsCreated: number;
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingDone({
|
||||
itemsCreated: _itemsCreated,
|
||||
onFinish,
|
||||
}: OnboardingDoneProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-8">
|
||||
<div className="max-w-2xl text-center">
|
||||
<div className="mb-6">
|
||||
<LucideIcon
|
||||
name="check-circle"
|
||||
size={48}
|
||||
className="text-gray-400 mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
You're all set!
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
Your collection is ready. Browse the catalog anytime to discover more
|
||||
gear.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFinish}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Start exploring
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/client/components/onboarding/OnboardingFlow.tsx
Normal file
157
src/client/components/onboarding/OnboardingFlow.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { getTagsForHobbies } from "@/shared/hobbyConfig";
|
||||
import {
|
||||
useCompleteOnboarding,
|
||||
usePopularItems,
|
||||
} from "../../hooks/useOnboarding";
|
||||
import { useUpdateSetting } from "../../hooks/useSettings";
|
||||
import { OnboardingDone } from "./OnboardingDone";
|
||||
import { OnboardingHobbyPicker } from "./OnboardingHobbyPicker";
|
||||
import { OnboardingItemBrowser } from "./OnboardingItemBrowser";
|
||||
import { OnboardingReview } from "./OnboardingReview";
|
||||
import { OnboardingWelcome } from "./OnboardingWelcome";
|
||||
import { StepIndicator } from "./StepIndicator";
|
||||
|
||||
type Step = "welcome" | "hobby" | "browse" | "review" | "done";
|
||||
|
||||
const STEP_PROGRESS: Record<Step, number> = {
|
||||
welcome: 20,
|
||||
hobby: 40,
|
||||
browse: 60,
|
||||
review: 80,
|
||||
done: 100,
|
||||
};
|
||||
|
||||
interface OnboardingFlowProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||
const [step, setStep] = useState<Step>("welcome");
|
||||
const [transitioning, setTransitioning] = useState(false);
|
||||
const [selectedHobbies, setSelectedHobbies] = useState<string[]>([]);
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
const [itemsCreated, setItemsCreated] = useState(0);
|
||||
|
||||
const completeOnboarding = useCompleteOnboarding();
|
||||
const updateSetting = useUpdateSetting();
|
||||
|
||||
// Fetch items for review step data
|
||||
const tags = getTagsForHobbies(selectedHobbies);
|
||||
const { data: popularItems } = usePopularItems(tags);
|
||||
|
||||
const goToStep = useCallback((nextStep: Step) => {
|
||||
setTransitioning(true);
|
||||
setTimeout(() => {
|
||||
setStep(nextStep);
|
||||
setTransitioning(false);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const handleToggleHobby = useCallback((hobbyId: string) => {
|
||||
setSelectedHobbies((prev) =>
|
||||
prev.includes(hobbyId)
|
||||
? prev.filter((h) => h !== hobbyId)
|
||||
: [...prev, hobbyId],
|
||||
);
|
||||
// Reset item selections when hobbies change
|
||||
setSelectedItemIds(new Set());
|
||||
}, []);
|
||||
|
||||
const handleToggleItem = useCallback((itemId: number) => {
|
||||
setSelectedItemIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(itemId)) next.delete(itemId);
|
||||
else next.add(itemId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRemoveItem = useCallback((itemId: number) => {
|
||||
setSelectedItemIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
const ids = [...selectedItemIds];
|
||||
completeOnboarding.mutate(ids, {
|
||||
onSuccess: (result) => {
|
||||
setItemsCreated(result.itemsCreated);
|
||||
goToStep("done");
|
||||
},
|
||||
});
|
||||
}, [selectedItemIds, completeOnboarding, goToStep]);
|
||||
|
||||
const handleSkipBrowse = useCallback(() => {
|
||||
updateSetting.mutate(
|
||||
{ key: "onboardingComplete", value: "true" },
|
||||
{ onSuccess: onComplete },
|
||||
);
|
||||
}, [updateSetting, onComplete]);
|
||||
|
||||
// Build review items from selected IDs
|
||||
const reviewItems = (popularItems || [])
|
||||
.filter((item) => selectedItemIds.has(item.id))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
brand: item.brand,
|
||||
model: item.model,
|
||||
imageUrl: item.imageUrl,
|
||||
category: item.category,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white overflow-y-auto">
|
||||
<StepIndicator progress={STEP_PROGRESS[step]} />
|
||||
|
||||
<div
|
||||
className={`transition-all duration-300 ${
|
||||
transitioning
|
||||
? "opacity-0 -translate-y-4"
|
||||
: "opacity-100 translate-y-0"
|
||||
}`}
|
||||
>
|
||||
{step === "welcome" && (
|
||||
<OnboardingWelcome onContinue={() => goToStep("hobby")} />
|
||||
)}
|
||||
|
||||
{step === "hobby" && (
|
||||
<OnboardingHobbyPicker
|
||||
selectedHobbies={selectedHobbies}
|
||||
onToggleHobby={handleToggleHobby}
|
||||
onContinue={() => goToStep("browse")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "browse" && (
|
||||
<OnboardingItemBrowser
|
||||
selectedHobbies={selectedHobbies}
|
||||
selectedItemIds={selectedItemIds}
|
||||
onToggleItem={handleToggleItem}
|
||||
onContinue={() => goToStep("review")}
|
||||
onSkip={handleSkipBrowse}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<OnboardingReview
|
||||
items={reviewItems}
|
||||
onRemoveItem={handleRemoveItem}
|
||||
onConfirm={handleConfirm}
|
||||
onSkip={handleSkipBrowse}
|
||||
isSubmitting={completeOnboarding.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "done" && (
|
||||
<OnboardingDone itemsCreated={itemsCreated} onFinish={onComplete} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/client/components/onboarding/OnboardingHobbyPicker.tsx
Normal file
47
src/client/components/onboarding/OnboardingHobbyPicker.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { HOBBIES } from "@/shared/hobbyConfig";
|
||||
import { HobbyCard } from "./HobbyCard";
|
||||
|
||||
interface OnboardingHobbyPickerProps {
|
||||
selectedHobbies: string[];
|
||||
onToggleHobby: (hobbyId: string) => void;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingHobbyPicker({
|
||||
selectedHobbies,
|
||||
onToggleHobby,
|
||||
onContinue,
|
||||
}: OnboardingHobbyPickerProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-8">
|
||||
<div className="max-w-2xl text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
What are you into?
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
Pick one or more — we'll show you popular gear for each.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4 mb-8">
|
||||
{HOBBIES.map((hobby) => (
|
||||
<HobbyCard
|
||||
key={hobby.id}
|
||||
name={hobby.name}
|
||||
icon={hobby.icon}
|
||||
descriptor={hobby.descriptor}
|
||||
selected={selectedHobbies.includes(hobby.id)}
|
||||
onClick={() => onToggleHobby(hobby.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
disabled={selectedHobbies.length === 0}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
src/client/components/onboarding/OnboardingItemBrowser.tsx
Normal file
123
src/client/components/onboarding/OnboardingItemBrowser.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { getTagsForHobbies } from "@/shared/hobbyConfig";
|
||||
import { usePopularItems } from "../../hooks/useOnboarding";
|
||||
import { SelectableItemCard } from "./SelectableItemCard";
|
||||
|
||||
interface OnboardingItemBrowserProps {
|
||||
selectedHobbies: string[];
|
||||
selectedItemIds: Set<number>;
|
||||
onToggleItem: (itemId: number) => void;
|
||||
onContinue: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingItemBrowser({
|
||||
selectedHobbies,
|
||||
selectedItemIds,
|
||||
onToggleItem,
|
||||
onContinue,
|
||||
onSkip,
|
||||
}: OnboardingItemBrowserProps) {
|
||||
const tags = getTagsForHobbies(selectedHobbies);
|
||||
const { data: items, isLoading } = usePopularItems(tags);
|
||||
|
||||
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">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Popular gear for{" "}
|
||||
{selectedHobbies.length === 1 ? selectedHobbies[0] : "your hobbies"}
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
Tap items you already own. We'll add them to your collection.
|
||||
</p>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-700 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !hasItems && (
|
||||
<div className="py-12 text-center">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No gear cataloged yet
|
||||
</h2>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
We're still building our catalog for this hobby. You can skip this
|
||||
step and add gear manually later.
|
||||
</p>
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Review {selectedItemIds.size}{" "}
|
||||
{selectedItemIds.size === 1 ? "item" : "items"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Skip this step
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
src/client/components/onboarding/OnboardingReview.tsx
Normal file
128
src/client/components/onboarding/OnboardingReview.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
|
||||
interface ReviewItem {
|
||||
id: number;
|
||||
brand: string | null;
|
||||
model: string;
|
||||
imageUrl: string | null;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
interface OnboardingReviewProps {
|
||||
items: ReviewItem[];
|
||||
onRemoveItem: (itemId: number) => void;
|
||||
onConfirm: () => void;
|
||||
onSkip: () => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export function OnboardingReview({
|
||||
items,
|
||||
onRemoveItem,
|
||||
onConfirm,
|
||||
onSkip,
|
||||
isSubmitting,
|
||||
}: OnboardingReviewProps) {
|
||||
// Group by category
|
||||
const grouped = new Map<string, ReviewItem[]>();
|
||||
for (const item of items) {
|
||||
const cat = item.category || "Uncategorized";
|
||||
if (!grouped.has(cat)) grouped.set(cat, []);
|
||||
grouped.get(cat)!.push(item);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-8">
|
||||
<div className="max-w-2xl w-full text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Your starting collection
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
{items.length > 0
|
||||
? `${items.length} ${items.length === 1 ? "item" : "items"} ready to add`
|
||||
: "No items selected — you can always add gear later from the catalog."}
|
||||
</p>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="text-left mb-8">
|
||||
{[...grouped.entries()].map(([category, catItems]) => (
|
||||
<div key={category} className="mb-4">
|
||||
<div className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">
|
||||
{category}
|
||||
</div>
|
||||
{catItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 py-2 border-b border-gray-50"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg overflow-hidden bg-gray-50 shrink-0">
|
||||
{item.imageUrl ? (
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.model}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<LucideIcon
|
||||
name="package"
|
||||
size={16}
|
||||
className="text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-gray-900 truncate">
|
||||
{item.brand
|
||||
? `${item.brand} ${item.model}`
|
||||
: item.model}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveItem(item.id)}
|
||||
className="text-gray-300 hover:text-red-500 transition-colors shrink-0"
|
||||
>
|
||||
<LucideIcon name="x" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{items.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isSubmitting}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isSubmitting ? "Adding..." : "Add to my collection"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Skip this step
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/client/components/onboarding/OnboardingWelcome.tsx
Normal file
26
src/client/components/onboarding/OnboardingWelcome.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
interface OnboardingWelcomeProps {
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingWelcome({ onContinue }: OnboardingWelcomeProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-8">
|
||||
<div className="max-w-2xl text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Welcome to GearBox
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 mb-8 leading-relaxed">
|
||||
Tell us what you're into, and we'll help you set up your collection
|
||||
with gear that people actually use.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Let's go
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/client/components/onboarding/SelectableItemCard.tsx
Normal file
85
src/client/components/onboarding/SelectableItemCard.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useFormatters } from "../../hooks/useFormatters";
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
|
||||
interface SelectableItemCardProps {
|
||||
brand: string | null;
|
||||
model: string;
|
||||
imageUrl: string | null;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
ownerCount: number;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function SelectableItemCard({
|
||||
brand,
|
||||
model,
|
||||
imageUrl,
|
||||
weightGrams,
|
||||
priceCents,
|
||||
ownerCount,
|
||||
selected,
|
||||
onClick,
|
||||
}: SelectableItemCardProps) {
|
||||
const { weight: formatWeight, price: formatPrice } = useFormatters();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`relative bg-white rounded-xl border text-left transition-all ${
|
||||
selected
|
||||
? "border-gray-700 ring-2 ring-gray-700/20"
|
||||
: "border-gray-100 hover:border-gray-200 hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{/* Selection indicator */}
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center ${
|
||||
selected
|
||||
? "bg-gray-700 border-gray-700"
|
||||
: "border-2 border-gray-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<LucideIcon name="check" size={14} className="text-white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="aspect-square bg-gray-50 rounded-t-xl overflow-hidden">
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={brand ? `${brand} ${model}` : model}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<LucideIcon name="package" size={32} className="text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3">
|
||||
{brand && <div className="text-xs text-gray-400 truncate">{brand}</div>}
|
||||
<div className="text-sm text-gray-900 font-medium truncate">
|
||||
{model}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-gray-400">
|
||||
{weightGrams != null && <span>{formatWeight(weightGrams)}</span>}
|
||||
{priceCents != null && <span>{formatPrice(priceCents)}</span>}
|
||||
</div>
|
||||
{ownerCount > 0 && (
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{ownerCount} {ownerCount === 1 ? "owner" : "owners"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
14
src/client/components/onboarding/StepIndicator.tsx
Normal file
14
src/client/components/onboarding/StepIndicator.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
interface StepIndicatorProps {
|
||||
progress: number; // 0 to 100
|
||||
}
|
||||
|
||||
export function StepIndicator({ progress }: StepIndicatorProps) {
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 h-1 bg-gray-100 z-50">
|
||||
<div
|
||||
className="h-1 bg-gray-700 transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 } };
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
44
src/client/hooks/useOnboarding.ts
Normal file
44
src/client/hooks/useOnboarding.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiGet, apiPost } from "../lib/api";
|
||||
|
||||
interface PopularItem {
|
||||
id: number;
|
||||
brand: string | null;
|
||||
model: string;
|
||||
category: string | null;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
imageFilename: string | null;
|
||||
imageUrl: string | null;
|
||||
description: string | null;
|
||||
ownerCount: number;
|
||||
}
|
||||
|
||||
/** Fetch popular catalog items for the given tags */
|
||||
export function usePopularItems(tags: string[]) {
|
||||
return useQuery({
|
||||
queryKey: ["popular-items", tags],
|
||||
queryFn: () =>
|
||||
apiGet<{ items: PopularItem[] }>(
|
||||
`/api/discovery/popular-items?tags=${tags.join(",")}&limit=24`,
|
||||
).then((res) => res.items),
|
||||
enabled: tags.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** Complete onboarding by batch-adding selected items */
|
||||
export function useCompleteOnboarding() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (globalItemIds: number[]) =>
|
||||
apiPost<{ itemsCreated: number; categoriesCreated: string[] }>(
|
||||
"/api/onboarding/complete",
|
||||
{ globalItemIds },
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }[];
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
|
||||
import { FabMenu } from "../components/FabMenu";
|
||||
import { OnboardingWizard } from "../components/OnboardingWizard";
|
||||
import { OnboardingFlow } from "../components/onboarding/OnboardingFlow";
|
||||
import { TopNav } from "../components/TopNav";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { useDeleteCandidate } from "../hooks/useCandidates";
|
||||
@@ -188,9 +188,9 @@ function RootLayout() {
|
||||
{/* Auth Prompt Modal */}
|
||||
<AuthPromptModal />
|
||||
|
||||
{/* Onboarding Wizard */}
|
||||
{/* Onboarding Flow */}
|
||||
{showWizard && (
|
||||
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
|
||||
<OnboardingFlow onComplete={() => setWizardDismissed(true)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { globalItemRoutes } from "./routes/global-items.ts";
|
||||
import { imageRoutes } from "./routes/images.ts";
|
||||
import { itemRoutes } from "./routes/items.ts";
|
||||
import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts";
|
||||
import { onboardingRoutes } from "./routes/onboarding.ts";
|
||||
import { profileRoutes } from "./routes/profiles.ts";
|
||||
import { settingsRoutes } from "./routes/settings.ts";
|
||||
import { setupRoutes } from "./routes/setups.ts";
|
||||
@@ -98,7 +99,16 @@ app.get("/login", oidcAuthMiddleware(), async (c) => c.redirect("/"));
|
||||
app.get("/callback", async (c) => processOAuthCallback(c));
|
||||
app.get("/logout", async (c) => {
|
||||
await revokeSession(c);
|
||||
return c.redirect("/login");
|
||||
const issuer = process.env.OIDC_ISSUER;
|
||||
const postLogoutRedirect = new URL("/", c.req.url).origin;
|
||||
if (issuer) {
|
||||
const clientId = process.env.OIDC_CLIENT_ID;
|
||||
const redirectUri = process.env.GEARBOX_URL || postLogoutRedirect;
|
||||
return c.redirect(
|
||||
`${issuer}/session/end?client_id=${encodeURIComponent(clientId || "")}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`,
|
||||
);
|
||||
}
|
||||
return c.redirect("/");
|
||||
});
|
||||
|
||||
// CORS for OAuth and MCP endpoints (required for claude.ai browser-based flows)
|
||||
@@ -191,6 +201,7 @@ app.route("/api/users", profileRoutes);
|
||||
app.route("/api/setups", setupRoutes);
|
||||
app.route("/api/discovery", discoveryRoutes);
|
||||
app.route("/api/global-items", globalItemRoutes);
|
||||
app.route("/api/onboarding", onboardingRoutes);
|
||||
app.route("/api/tags", tagRoutes);
|
||||
|
||||
// MCP server (conditionally mounted)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
getPopularItemsByTags,
|
||||
getPopularSetups,
|
||||
getRecentGlobalItems,
|
||||
getTrendingCategories,
|
||||
@@ -35,4 +36,22 @@ app.get("/categories", async (c) => {
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
app.get("/popular-items", async (c) => {
|
||||
const db = c.get("db");
|
||||
const tagsParam = c.req.query("tags") || "";
|
||||
const limitParam = c.req.query("limit");
|
||||
const tagNames = tagsParam
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const limit = limitParam ? Math.min(Number.parseInt(limitParam, 10), 50) : 24;
|
||||
|
||||
if (tagNames.length === 0) {
|
||||
return c.json({ items: [] });
|
||||
}
|
||||
|
||||
const results = await getPopularItemsByTags(db, tagNames, limit);
|
||||
return c.json({ items: results });
|
||||
});
|
||||
|
||||
export { app as discoveryRoutes };
|
||||
|
||||
24
src/server/routes/onboarding.ts
Normal file
24
src/server/routes/onboarding.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { completeOnboardingSchema } from "../../shared/schemas.ts";
|
||||
import { completeOnboarding } from "../services/onboarding.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any; userId?: number } };
|
||||
|
||||
const app = new Hono<Env>();
|
||||
|
||||
// POST /api/onboarding/complete
|
||||
app.post(
|
||||
"/complete",
|
||||
zValidator("json", completeOnboardingSchema),
|
||||
async (c) => {
|
||||
const database = c.get("db");
|
||||
const userId = c.get("userId")!;
|
||||
const { globalItemIds } = c.req.valid("json");
|
||||
|
||||
const result = await completeOnboarding(database, userId, globalItemIds);
|
||||
return c.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
export { app as onboardingRoutes };
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { and, count, desc, eq, isNotNull, lt, sql } from "drizzle-orm";
|
||||
import { and, count, desc, eq, inArray, isNotNull, lt, sql } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { globalItems, setupItems, setups, users } from "../../db/schema.ts";
|
||||
import {
|
||||
globalItems,
|
||||
globalItemTags,
|
||||
items,
|
||||
setupItems,
|
||||
setups,
|
||||
tags,
|
||||
users,
|
||||
} from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
@@ -125,3 +133,53 @@ export async function getTrendingCategories(
|
||||
itemCount: r.itemCount,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular global items filtered by tag names, ordered by owner count descending.
|
||||
* Owner count = number of user items linked to each global item via globalItemId.
|
||||
*/
|
||||
export async function getPopularItemsByTags(
|
||||
db: Db = prodDb,
|
||||
tagNames: string[],
|
||||
limit = 24,
|
||||
): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
brand: string | null;
|
||||
model: string;
|
||||
category: string | null;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
imageUrl: string | null;
|
||||
description: string | null;
|
||||
ownerCount: number;
|
||||
}>
|
||||
> {
|
||||
if (tagNames.length === 0) return [];
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: globalItems.id,
|
||||
brand: globalItems.brand,
|
||||
model: globalItems.model,
|
||||
category: globalItems.category,
|
||||
weightGrams: globalItems.weightGrams,
|
||||
priceCents: globalItems.priceCents,
|
||||
imageUrl: globalItems.imageUrl,
|
||||
description: globalItems.description,
|
||||
ownerCount: sql<number>`CAST(COUNT(DISTINCT ${items.id}) AS INT)`,
|
||||
})
|
||||
.from(globalItems)
|
||||
.innerJoin(globalItemTags, eq(globalItemTags.globalItemId, globalItems.id))
|
||||
.innerJoin(tags, eq(tags.id, globalItemTags.tagId))
|
||||
.leftJoin(items, eq(items.globalItemId, globalItems.id))
|
||||
.where(inArray(tags.name, tagNames))
|
||||
.groupBy(globalItems.id)
|
||||
.orderBy(
|
||||
desc(sql<number>`COUNT(DISTINCT ${items.id})`),
|
||||
desc(globalItems.id),
|
||||
)
|
||||
.limit(limit);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user