Example app spec: a used-gear marketplace

THE WHOLE IDEA WE TYPED

“A marketplace for used climbing gear”

Generated by SpecWise with the Balanced model (3 credits) · 2,725 words · unedited

Want one for your idea?

Describe your idea in a sentence and get a spec like this in about a minute.

Generate my spec

7-day free trial · no card needed

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 sold after 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_year to 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, Sling or Helmet with manufacture_year older than 10 years shows a red Expired? warning badge and cannot be set to active with condition Like 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_changes on messages) → live chat
  • Styling → Tailwind CSS → mobile-first layout
  • Dates → date-fns → relative times like 3h ago
  • Deployment → Vercel → zero-config Next.js hosting

3) Users & Screens

RoleCanCannot
Visitor (signed out)Browse, search, filter, open listingsMessage, post, favourite
Member (signed in)Everything a visitor can, plus post listings, edit or delete own listings, message sellers, favourite, mark own listing soldEdit others' listings, read others' chats

Screens:

ScreenReached fromShowsEmpty / loading / errorBack / close goes to
BrowseHeader logo, /Search box, filter bar, listing gridsee 6.1n/a
Listing detailGrid cardPhotos, specs, seller, Message sellersee 6.2Browse (keeps filters)
Sign inAny gated actionEmail field, Send magic linksee 6.3Previous page
New / edit listingHeader Sell gear, Edit on own listingFormsee 6.4My listings
Inbox / chatHeader MessagesConversation list, threadsee 6.5Inbox list
My listingsHeader avatar menuOwn listings by statussee 6.4Browse

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:

FromEventToSide effect
activeSeller taps Mark as soldsoldsold_at set; hidden from Browse; chats stay open
activeSeller taps Delete listing and confirmsremovedHidden everywhere; chats become read-only
soldSeller taps Relistactivesold_at cleared
removednoneterminalPurged 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:

  1. Visitor opens /; the grid loads 24 active listings, newest first, each card showing the first photo, title, $price, condition, seller city.
  2. Visitor types in the search box Search gear; results filter on title and description after 300 ms.
  3. Visitor opens Filters: category (multi-select), condition (multi-select), price min/max, Hide expired-age safety gear toggle (off by default).
  4. Sort select: Newest, Price: low to high, Price: high to low.
  5. Load more appends the next 24. UI Copy & Colors:
  • Search placeholder: Search gear, text #0F172A on #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 filters button.
  • Load failure → Couldn't load listings. Recovery: Retry. Acceptance Checks:
  • ✓ Category Rope + price max 100 shows only active Rope listings priced ≤ $100.
  • ✓ Sort Price: low to high puts a $40 listing above a $75 listing.
  • ✓ A sold listing never appears in the grid.
  • ✓ Toggling Hide expired-age safety gear hides a 2013 Rope in March 2025.

6.2 Listing Detail

User Flow:

  1. 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.
  2. Safety categories show a Safety info box: Manufactured {year} ({age} years old) and Fall history: {value}.
  3. Message seller opens the chat (signed-in) or Sign in.
  4. Heart button toggles favourite. UI Copy & Colors:
  • Safety box: text #0F172A on #FEF3C7, border #F59E0B.
  • Message seller: white on #2563EB; on own listing replaced by Edit listing. Error & Empty States:
  • Listing removed or not found → This listing is no longer available. Recovery: Back to Browse.
  • Sold → banner Sold white on #64748B; Message seller disabled. Acceptance Checks:
  • ✓ A 2013 Rope viewed in 2025 shows Manufactured 2013 (12 years old) and the Expired? badge.
  • ✓ A Shoes listing shows no Safety info box.
  • ✓ A sold listing shows the Sold banner and a disabled message button.

6.3 Sign In

User Flow:

  1. User enters an email and taps Send magic link.
  2. The screen shows Check your email for the sign-in link.
  3. The link opens the app signed in; first-time users land on a profile form asking Display name and City, saved with Save profile.
  4. The user returns to the page that triggered sign-in. UI Copy & Colors:
  • Heading Sign in to Crux Swap; field label Email; 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.com shows 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:

  1. Member taps Sell gear; the form asks Title, Category, Condition, Price (USD), Size (optional), Description, Photos (1-6, drag or pick).
  2. Choosing a safety category reveals Year manufactured and Fall history, both required.
  3. Tapping Publish validates, uploads photos, saves with status = active, and opens the listing.
  4. My listings has tabs Active, Sold, each row with Edit, Mark as sold (or Relist), Delete listing.
  5. Delete listing asks Delete this listing? This can't be undone. with Delete and Cancel. UI Copy & Colors:
  • Publish white on #16A34A; Delete white 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. with Sell gear button. Acceptance Checks:
  • ✓ A Rope from 2012 with condition Like new is rejected with the message above.
  • ✓ A seventh photo is refused with Maximum 6 photos.
  • ✓ Mark as sold moves a listing from Active to Sold and removes it from Browse.
  • ✓ A price of 5001 is rejected with Price must be $1-$5,000.

6.5 Messages

User Flow:

  1. Buyer taps Message seller; a conversation is created if none exists, otherwise the existing one opens.
  2. Buyer types in Write a message (≤500) and taps Send; the message appears instantly for both sides via Realtime.
  3. Messages lists conversations by latest message, showing listing thumbnail, other person's name, last message preview, unread dot.
  4. Opening a thread sets read_at on the other party's messages; the header badge updates. UI Copy & Colors:
  • Own bubbles #2563EB with white text; other bubbles #F1F5F9 with 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. with Browse gear button.
  • Send failure → Message not sent. Recovery: Retry under 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 (Send disabled, counter 501/500 in red).

7) UI/UX

Palette:

TokenHexUsed for
Primary#2563EBButtons, links, own chat bubbles
Success#16A34APublish, confirmations
Warning#F59E0BSafety box border
Danger#DC2626Expired? badge, destructive buttons
Text#0F172ABody text
Muted#64748BCaptions, sold banner
Surface#F8FAFCPage background
Card#FFFFFFCards, 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:

InteractionDurationEasing
Filter panel open200 msease-out
Carousel slide250 msease-in-out
Heart toggle150 msease-out
New message appear150 msease-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:

LimitPlatforms affectedDesign answer
Large phone photosAll mobile browsersResize to max 1,600 px wide in the browser before upload
Magic link opens in a different browseriPhone mail appsSign-in page says Open the link in the same browser you used here.
No push while closedAllUnread badge only; no notifications in V1

9) Out of Scope (V1)

  1. In-app payments - tempting, but holding money needs licensing and dispute handling; buyers and sellers settle directly.
  2. Shipping labels and rates - tempting, but carriers and weight tables double the scope; chat arranges delivery.
  3. Ratings and reviews - needs completed-deal verification first; the Sold status is the only trust signal.
  4. Offers and counter-offers - price talk happens in chat for now.
  5. Push or email notifications - the unread badge covers open sessions.
  6. Gear authentication or inspection service - the safety badge is advisory, not a certification.
  7. Saved searches and price alerts - favourites cover the basic need.

10) Success Criteria

  • ✓ Entering ana@example.com shows Check 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 2013 shows Expired? and Manufactured 2013 (12 years old) in 2025.
  • ✓ Publishing a 2012 Harness as Like new is 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 sold removes the listing from Browse and shows a Sold banner on its page.
  • ✓ A signed-out visitor tapping Message seller is sent to Sign in and returned to the same listing afterward.
  • ✓ No console errors during browse, post, and message flow.

Spec your own app

Describe your idea in a sentence and get a spec like this in about a minute.

Generate my spec

7-day free trial · no card needed

More examples