Crux Swap - Product Requirements Document
1) Summary
Crux Swap is a web marketplace where climbers buy and sell used climbing gear (shoes, harnesses, cams, quickdraws, ropes, helmets, apparel) to each other. Sellers sign in with an email magic link, post a listing with up to 6 photos, a price, a condition grade and a gear category; buyers browse, filter, message sellers, and mark a deal done. Because safety-critical soft goods are risky second-hand, ropes, harnesses, slings and helmets require a disclosed manufacture year and a "never fallen on / unknown" history answer. Data lives in Supabase (Postgres, Auth, Storage, Realtime).
- Assumption: Server-backed, because buyers and sellers are different people on different devices and messages must arrive live.
- Assumption: "Sold" means the seller marked the listing
soldafter agreeing a price in chat; no payment passes through the app, so the app never holds money. - Assumption: Gear age is measured in whole years from
manufacture_yearto the current calendar year; no per-day arithmetic is used. - Assumption: Limits: title 60 chars, description 1,000 chars, price 1-5,000 whole US dollars, message 500 chars, 6 photos per listing, 25 active listings per seller.
- Assumption: Safety gate: a listing in category
Rope,Harness,SlingorHelmetwithmanufacture_yearolder than 10 years shows a redExpired?warning badge and cannot be set toactivewith conditionLike new. - Assumption: Pitch silent on delivery; the app only shows the seller's city and lets the pair agree meetup or shipping in chat.
- Assumption: Browser app for phone and desktop, not installable, no push; new messages show as an unread badge while the app is open.
- Assumption: Not automatic: no payments, no shipping labels, no price negotiation tool, no automatic delisting.
2) Tech Stack
- Framework → Next.js 14 (App Router) → server components for fast listing pages
- Language → TypeScript → typed data model
- Database → Supabase Postgres with row-level security → per-user write rules
- Auth → Supabase Auth, email magic link → no passwords to manage
- File storage → Supabase Storage bucket
listing-photos→ listing photos - Live updates → Supabase Realtime (
postgres_changesonmessages) → live chat - Styling → Tailwind CSS → mobile-first layout
- Dates →
date-fns→ relative times like3h ago - Deployment → Vercel → zero-config Next.js hosting
3) Users & Screens
| Role | Can | Cannot |
|---|---|---|
| Visitor (signed out) | Browse, search, filter, open listings | Message, post, favourite |
| Member (signed in) | Everything a visitor can, plus post listings, edit or delete own listings, message sellers, favourite, mark own listing sold | Edit others' listings, read others' chats |
Screens:
| Screen | Reached from | Shows | Empty / loading / error | Back / close goes to |
|---|---|---|---|---|
| Browse | Header logo, / | Search box, filter bar, listing grid | see 6.1 | n/a |
| Listing detail | Grid card | Photos, specs, seller, Message seller | see 6.2 | Browse (keeps filters) |
| Sign in | Any gated action | Email field, Send magic link | see 6.3 | Previous page |
| New / edit listing | Header Sell gear, Edit on own listing | Form | see 6.4 | My listings |
| Inbox / chat | Header Messages | Conversation list, thread | see 6.5 | Inbox list |
| My listings | Header avatar menu | Own listings by status | see 6.4 | Browse |
Navigation: header with logo, search link, Sell gear, Messages (unread badge), avatar menu (My listings, Favourites, Sign out). Favourites is a filtered Browse view (saved=1), not a separate screen.
4) Data Model & Logic
profiles: id (uuid = auth user id), display_name (text, 2-30 chars), city (text, 2-40 chars, example Boulder, CO), created_at.
listings: id, seller_id (→ profiles), title (≤60), description (≤1,000), category (enum Shoes, Harness, Rope, Sling, Helmet, Cams & Nuts, Quickdraws, Apparel, Other), condition (enum New, Like new, Good, Worn, For parts), price_usd (int 1-5,000), size (text, optional, ≤12), manufacture_year (int, required for safety categories, 1990 to current year), fall_history (enum Never fallen on, Light falls, Unknown; required for safety categories), photos (text[] storage paths, 1-6), status (enum active, sold, removed; default active), created_at, sold_at.
favourites: user_id, listing_id; unique pair.
conversations: id, listing_id, buyer_id, created_at; unique (listing_id, buyer_id); seller is listings.seller_id.
messages: id, conversation_id, sender_id, body (≤500), created_at, read_at (null until the other party opens the thread).
Uniqueness, sort and retention: one conversation per buyer per listing. Browse sorts by created_at desc by default. Removed listings are soft-deleted (status = removed) and kept 30 days, then purged by a daily job.
Logic:
safetyCategory = category in [Rope, Harness, Sling, Helmet]
age = currentYear - manufacture_year
expiredFlag = safetyCategory && age > 10
canPublish = !(expiredFlag && condition == "Like new") && photos.length >= 1
unreadCount(user) = count(messages where read_at is null and sender_id != user
and conversation involves user)
Worked example (today is 2025-03-02): Listing A, Rope, manufacture_year 2013 → age 12 → Expired? badge; condition Like new is rejected, Good is allowed. Listing B, Helmet, 2019 → age 6, no badge. Listing C, Shoes, no year needed, no badge. Buyer Ana messages at 23:58 on 2025-03-01; the seller reads it at 00:03 on 2025-03-02: read_at = 2025-03-02 00:03 and the thread shows Yesterday 23:58 under date-fns isYesterday rules in the viewer's local time zone.
Status transitions:
| From | Event | To | Side effect |
|---|---|---|---|
active | Seller taps Mark as sold | sold | sold_at set; hidden from Browse; chats stay open |
active | Seller taps Delete listing and confirms | removed | Hidden everywhere; chats become read-only |
sold | Seller taps Relist | active | sold_at cleared |
removed | none | terminal | Purged after 30 days |
Edits and edge cases: editing price or title never notifies buyers. Changing a category to a safety category after posting makes year and fall history required before saving. Seller at 25 active listings sees Limit reached: 25 active listings on Sell gear. A seller cannot open a conversation on their own listing. Deleting a listing never deletes its messages.
5) External Services
Supabase
Used for: Auth, Postgres, Storage, Realtime.
Calls: auth.signInWithOtp({ email }), from('listings').select/insert/update, storage.from('listing-photos').upload, channel().on('postgres_changes', …) on messages.
Fields read: all columns in 4; profiles.display_name and city shown publicly, email never shown.
Limits: photos max 5 MB each, JPEG/PNG/WebP only; the free tier magic-link email limit applies.
Failure: any failed call shows Something went wrong. Please try again. with a Retry button; the form keeps its entries.
Keys: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY (browser, safe with row-level security).
6) Core Features
6.1 Browse & Filter
User Flow:
- Visitor opens
/; the grid loads 24 active listings, newest first, each card showing the first photo,title,$price,condition, seller city. - Visitor types in the search box
Search gear; results filter on title and description after 300 ms. - Visitor opens
Filters: category (multi-select), condition (multi-select), price min/max,Hide expired-age safety geartoggle (off by default). - Sort select:
Newest,Price: low to high,Price: high to low. Load moreappends the next 24. UI Copy & Colors:
- Search placeholder:
Search gear, text#0F172Aon#FFFFFF. Expired?badge:Expired?white on#DC2626.- Heart button on cards toggles favourite (signed-in only). Error & Empty States:
- No results →
No gear matches. Try fewer filters.Recovery:Clear filtersbutton. - Load failure →
Couldn't load listings.Recovery:Retry. Acceptance Checks: - ✓ Category
Rope+ price max100shows only active Rope listings priced ≤ $100. - ✓ Sort
Price: low to highputs a $40 listing above a $75 listing. - ✓ A sold listing never appears in the grid.
- ✓ Toggling
Hide expired-age safety gearhides a 2013 Rope in March 2025.
6.2 Listing Detail
User Flow:
- Visitor taps a card; the page shows a photo carousel (swipe or arrow buttons),
title,$price, condition chip, category, size, description, seller name and city. - Safety categories show a
Safety infobox:Manufactured {year} ({age} years old)andFall history: {value}. Message selleropens the chat (signed-in) or Sign in.- Heart button toggles favourite. UI Copy & Colors:
- Safety box: text
#0F172Aon#FEF3C7, border#F59E0B. Message seller: white on#2563EB; on own listing replaced byEdit listing. Error & Empty States:- Listing removed or not found →
This listing is no longer available.Recovery:Back to Browse. - Sold → banner
Soldwhite on#64748B;Message sellerdisabled. Acceptance Checks: - ✓ A 2013 Rope viewed in 2025 shows
Manufactured 2013 (12 years old)and theExpired?badge. - ✓ A Shoes listing shows no Safety info box.
- ✓ A sold listing shows the
Soldbanner and a disabled message button.
6.3 Sign In
User Flow:
- User enters an email and taps
Send magic link. - The screen shows
Check your email for the sign-in link. - The link opens the app signed in; first-time users land on a profile form asking
Display nameandCity, saved withSave profile. - The user returns to the page that triggered sign-in. UI Copy & Colors:
- Heading
Sign in to Crux Swap; field labelEmail; button white on#2563EB. Error & Empty States: - Invalid email →
Enter a valid email address.Recovery: fix the field. - Expired link →
That link has expired.Recovery:Send a new link. Acceptance Checks: - ✓
ana@example.comshows the check-email message. - ✓ A new user cannot leave the profile form with a 1-character display name (
Display name must be 2-30 characters.). - ✓ After sign-in from a listing's
Message seller, the user lands back on that listing.
6.4 Sell Gear & My Listings
User Flow:
- Member taps
Sell gear; the form asksTitle,Category,Condition,Price (USD),Size(optional),Description,Photos(1-6, drag or pick). - Choosing a safety category reveals
Year manufacturedandFall history, both required. - Tapping
Publishvalidates, uploads photos, saves withstatus = active, and opens the listing. My listingshas tabsActive,Sold, each row withEdit,Mark as sold(orRelist),Delete listing.Delete listingasksDelete this listing? This can't be undone.withDeleteandCancel. UI Copy & Colors:
Publishwhite on#16A34A;Deletewhite on#DC2626. Error & Empty States:- Missing photo →
Add at least 1 photo.Recovery: add a photo. - Like new on expired safety gear →
Gear older than 10 years can't be listed as Like new.Recovery: change condition. - My listings empty →
You haven't listed anything yet.withSell gearbutton. Acceptance Checks: - ✓ A Rope from 2012 with condition
Like newis rejected with the message above. - ✓ A seventh photo is refused with
Maximum 6 photos. - ✓
Mark as soldmoves a listing fromActivetoSoldand removes it from Browse. - ✓ A price of
5001is rejected withPrice must be $1-$5,000.
6.5 Messages
User Flow:
- Buyer taps
Message seller; a conversation is created if none exists, otherwise the existing one opens. - Buyer types in
Write a message(≤500) and tapsSend; the message appears instantly for both sides via Realtime. Messageslists conversations by latest message, showing listing thumbnail, other person's name, last message preview, unread dot.- Opening a thread sets
read_aton the other party's messages; the header badge updates. UI Copy & Colors:
- Own bubbles
#2563EBwith white text; other bubbles#F1F5F9with text#0F172A. - Safety tip pinned in each thread:
Inspect safety gear in person before paying.Error & Empty States: - No conversations →
No messages yet. Find gear to ask about.withBrowse gearbutton. - Send failure →
Message not sent.Recovery:Retryunder the bubble. - Removed listing → input replaced by
This listing was removed.Acceptance Checks: - ✓ A message sent from one browser appears in the other within 2 s without reload.
- ✓ Two messages unread by the seller show badge
2; opening the thread clears it. - ✓ A 501-character message cannot be sent (
Senddisabled, counter501/500in red).
7) UI/UX
Palette:
| Token | Hex | Used for |
|---|---|---|
| Primary | #2563EB | Buttons, links, own chat bubbles |
| Success | #16A34A | Publish, confirmations |
| Warning | #F59E0B | Safety box border |
| Danger | #DC2626 | Expired? badge, destructive buttons |
| Text | #0F172A | Body text |
| Muted | #64748B | Captions, sold banner |
| Surface | #F8FAFC | Page background |
| Card | #FFFFFF | Cards, forms |
Typography: Inter, 16 px base; page headings 24 px bold; price 20 px semibold; captions 13 px. Layout: mobile first; grid is 2 columns under 768 px, 4 columns at 1024 px and up, max width 1200 px centred; listing detail is single column on phone, photos left and details right at 1024 px. Motion:
| Interaction | Duration | Easing |
|---|---|---|
| Filter panel open | 200 ms | ease-out |
| Carousel slide | 250 ms | ease-in-out |
| Heart toggle | 150 ms | ease-out |
| New message appear | 150 ms | ease-out |
Timestamp display: listing age as relative (formatDistanceToNow, e.g. 3 hours ago); chat times h:mm a today, EEE h:mm a this week, MMM d older.
Accessibility: text contrast ≥ 4.5:1, tap targets ≥ 44×44 px, visible focus ring, aria-label on heart, carousel arrows and icon buttons, alt text on photos from the title.
8) Infrastructure Notes
Environment variables: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY (browser-safe). SUPABASE_SERVICE_ROLE_KEY (server only) used by the purge job.
Client vs server: Browse and detail pages render on the server; forms, chat and favourites run in the client with the anon key. Row-level security: anyone reads active listings; only seller_id writes a listing; only the two chat participants read or write that conversation's messages. The 30-day purge runs once daily as a Vercel cron route using the service-role key.
Platform limits:
| Limit | Platforms affected | Design answer |
|---|---|---|
| Large phone photos | All mobile browsers | Resize to max 1,600 px wide in the browser before upload |
| Magic link opens in a different browser | iPhone mail apps | Sign-in page says Open the link in the same browser you used here. |
| No push while closed | All | Unread badge only; no notifications in V1 |
9) Out of Scope (V1)
- In-app payments - tempting, but holding money needs licensing and dispute handling; buyers and sellers settle directly.
- Shipping labels and rates - tempting, but carriers and weight tables double the scope; chat arranges delivery.
- Ratings and reviews - needs completed-deal verification first; the
Soldstatus is the only trust signal. - Offers and counter-offers - price talk happens in chat for now.
- Push or email notifications - the unread badge covers open sessions.
- Gear authentication or inspection service - the safety badge is advisory, not a certification.
- Saved searches and price alerts - favourites cover the basic need.
10) Success Criteria
- ✓ Entering
ana@example.comshowsCheck your email for the sign-in link. - ✓ A member can publish a Shoes listing with 1 photo, price
$45, and see it first in Browse. - ✓ A Rope listing with year
2013showsExpired?andManufactured 2013 (12 years old)in 2025. - ✓ Publishing a 2012 Harness as
Like newis rejected with the exact message in 6.4. - ✓ The seventh photo upload is refused with
Maximum 6 photos. - ✓ A message sent in one browser appears in the other within 2 s.
- ✓ Two unread messages show badge
2, and opening the thread clears it. - ✓
Mark as soldremoves the listing from Browse and shows aSoldbanner on its page. - ✓ A signed-out visitor tapping
Message selleris sent to Sign in and returned to the same listing afterward. - ✓ No console errors during browse, post, and message flow.