Skip to content

[Parked · Feature] Carousell-style Make Offer / bid pricing for shop materials & one-offs #490

Open

Description

@owlstonbusiness-svg

Header


Problem

Live Notes Shop is fixed-price Buy Now. Sellers of one-off / scarce items (physical kits, limited note packs, unique freestyle deliverables) and buyers who want to negotiate like on Carousell have no structured offer path. Today negotiation leaks to WhatsApp / phone in descriptions — which Shop rules already reject — or tutors silently edit list price (#318) with no buyer-initiated bid trail.

Likely code path: locate during impl under src/features/materials/* (PDP / buy CTA), seller note/course/folder editors, and Notes Shop order creation / escrow unlock. Exact table for offers: see Implementation checklist (new material_offers table or JSONB on listing — justify at impl; prefer dedicated table if concurrent offers need indexes).


CEO / product lock

Ship when unparked Park (later / out of scope)
Fixed Buy Now remains default. Offer/bid is opt-in per listing (offers_enabled seller toggle). Timed public auction with countdown / highest-bidder-wins clock.
Applies to digital_materials one-shot sales (notes, packs, physical kits, one-off digital goods) after seller enables offers. Vehicles / memberships / AI mocks (#474).
Accepted offer → existing Notes Shop money path: 20% fee + escrow hold (#203/#269/#270/#429). Same delivery_payload unlock after payment. Second cashier, Stripe Connect fork, or “offer wallet”.
Make Offer / Counter / Decline (Carousell-like private negotiation). Propose 48h seller response window (Open question). Promo codes, BOGO, multi-item cart, strike-through “was” engine (#318 still holds).
Privacy: never show other buyers’ offer amounts publicly. Native DRM video, SF Express API, paid CPC/boost bidding (same Materials Pack out-of-scope).
Structured amount + optional short note; contact filter (no phone/WA/email in offer notes). Guest checkout offers without login (Open question — default propose login required).

Exact behaviour

Triggers

  • Seller: Authenticated seller edits a digital_materials listing (notes / folders / courses / physical one-shots — kinds that are one-shot shop sales). Enables Accept offers / 接受出價.
  • Buyer: Authenticated buyer on public PDP / card where offers_enabled = true clicks Make Offer / 出價.
  • URL: locate during impl (existing material detail + account seller inbox / offer desk).

Step-by-step flow

  1. Seller toggles offers_enabled = true.
  2. Seller optionally sets min_offer_hkd (MUST be strictly less than current Buy Now / selected-variant list price; if variants exist, min applies vs the variant’s price_hkd the buyer is bidding on).
  3. Seller optionally sets auto_accept_hkd (≥ min_offer_hkd, ≤ list price). Offers ≥ this threshold auto-Accept.
  4. Buyer opens PDP → sees Buy Now HK$X + Make Offer CTA (CTA hidden when offers_enabled = false or listing sold-out / unpublished).
  5. Buyer submits offer_amount_hkd + optional short note (≤ N chars; contact filter). Server stamps created_at in HKT.
  6. Rules on submit:
    • MUST reject if offer_amount_hkd < min_offer_hkd (when set) or offer_amount_hkd ≥ list_price (use Buy Now instead — Open question: allow equal-to-list as “offer” or force Buy Now).
    • MUST enforce one active offer per buyer per listing (and per variant_id if variants): new submit cancels previous active offer from same buyer (idempotent; no duplicate actives).
  7. Seller inbox shows pending offers: Accept / Counter / Decline.
    • Accept → create shop order at accepted price → buyer pays via existing cashier → escrow hold → unlock delivery_payload only after payment/confirm same as Buy Now.
    • Counter → seller proposes counter_amount_hkd; buyer Accept / Decline / re-offer (still one active thread).
    • Decline → offer closes; buyer may submit a new offer later.
  8. Expiry: if seller does not act within 48 hours HKT (propose), offer status → expired; buyer notified in-app (channel: locate during impl; MUST NOT SMS/WA-blast phones).
  9. Public card/PDP: show Buy Now + Make Offer only. MUST NOT show other buyers’ amounts, names, or bid counts that reveal private negotiation (Open question: anonymized “N offers” badge yes/no — default no).

Data in / data out / side effects

Direction Fields
In (seller config) offers_enabled, min_offer_hkd?, auto_accept_hkd? on listing (or listing+variant).
In (buyer offer) listing_id, variant_id?, buyer_id, offer_amount_hkd, note?, HKT timestamp.
Out Offer record with status pending | countered | accepted | declined | expired | cancelled.
Side effects on Accept Create order at accepted price; platform fee 20% of accepted price; escrow path unchanged; delivery_payload stays private until paid/unlocked.
Side effects on Cancel/new offer Prior active offer from same buyer → cancelled.

Rules (MUST / MUST NOT)


UI / look

Public PDP (buyer)

┌─────────────────────────────────────────┐
│  [Cover]  Title · Seller · Hot/Legit    │
│  Buy Now  HK$280                        │
│  [ Buy Now / 立即購買 ]                 │
│  [ Make Offer / 出價 ]   ← if enabled   │
│  (no other bids visible)                │
└─────────────────────────────────────────┘

Make Offer sheet:
  Your offer (HK$)  [____]
  Note (optional)   [____________]
  Min hint if set: "最低出價 HK$180"
  [ Submit offer / 提交出價 ]

Seller offer inbox

Offers on “DSE Chem Pack”
  Buyer A · HK$200 · 2h ago · [Accept] [Counter] [Decline]
  Buyer B · HK$220 · 10m ago · [Accept] [Counter] [Decline]

Copy EN + 繁中

Key EN 繁中
CTA Make Offer 出價
Buy Now Buy Now 立即購買
Toggle Accept offers on this listing 接受此商品出價
Min Minimum offer (HK$) 最低出價(港元)
Auto Auto-accept at or above (HK$) 自動接受出價不低於(港元)
Pending Waiting for seller 等待賣家回覆
Accept Accept 接受
Counter Counter 還價
Decline Decline 拒絕
Expired Offer expired 出價已過期
Privacy Your offer is private to the seller 你的出價僅賣家可見
Contact reject Remove phone / WhatsApp / email from your note 請移除電話/WhatsApp/電郵

Component states

State Behaviour
Empty (no offers) Seller inbox: “No offers yet / 尚未有出價”
Loading Skeleton on inbox + sheet
Error Inline validation (below min, ≥ list, contact filter, network)
Success submit Toast + “Waiting for seller”; sheet closes
Success accept Redirect / deep-link to existing checkout for that order
Sold out / unpublished Hide Make Offer + Buy Now; pending offers auto-expire or decline (edge table)

Logic & edge cases

Case Expected
Cold start, offers off Only Buy Now; no Make Offer CTA
Offers on, no min set Any positive amount < list price allowed (floor Open question: HK$1 vs sensible floor)
Offer ≥ list price Reject with “Use Buy Now” (or Open question equal-allowed)
Offer < min Reject with min hint
Offer ≥ auto_accept Auto-Accept → create order at offer amount; notify both
Second offer same buyer Cancels prior active; new pending
Two buyers race Accept on last stock=1 First successful order wins; second Accept fails with stock error; offer returns to actionable or expired — locate inventory field during impl
Seller disables offers mid-flight Pending offers remain actionable until expiry or auto-decline all — Open question; propose: remain until 48h then expire
Listing price edited (#318) while offers pending Re-validate pending vs new list/min on seller action; if offer now ≥ new list → prompt Buy Now path
Variants (#487) Offer binds to variant_id; min/auto relative to that variant price
Physical meetup / SF Same offer flow; fulfilment still seller-arranged per listing (no SF API)
Mobile vs desktop Same CTAs; sheet = bottom sheet mobile / modal desktop
Idempotent Accept Unique constraint / transaction so one order per accepted offer
Guest user Block submit — login required (default; Open question)

Implementation checklist

  1. Schema (justify one):
    • Prefer table material_offers (id, listing_id, variant_id null, buyer_id, seller_id, amount_hkd, counter_amount_hkd null, note, status, expires_at, timestamps) with unique partial index: one pending|countered per (listing_id, variant_id, buyer_id).
    • Avoid stuffing concurrent offers into listing JSONB unless volume is proven tiny — indexes/expiry jobs are harder.
  2. Listing columns on digital_materials (or settings JSON already used by editors): offers_enabled bool default false, min_offer_hkd, auto_accept_hkd.
  3. Editors: src/features/materials/note-editor-form.tsx, src/features/courses/course-editor.tsx, src/features/folders/folder-editor.tsx (+ services/gig editor if freestyle gets offers — Open question).
  4. Buyer UI: material PDP / card Buy CTA component (locate under shop/materials public routes).
  5. Seller inbox: account route for offers (locate; may sit beside existing orders desk).
  6. Accept → order: call existing Notes Shop order creator with price_paid = accepted_amount; fee 20%; escrow unchanged; pass offer_id for audit.
  7. Jobs: HKT expiry sweeper every N minutes for pending/countered past expires_at.
  8. Contact filter: reuse Shop description sanitizer on note.
  9. MUST NOT new microservice; MUST NOT new payment provider.

Pseudo-code:

submit_offer(buyer, listing, variant?, amount, note):
  assert logged_in
  assert listing.offers_enabled and listing.buyable
  price = variant.price_hkd ?? listing.price_hkd
  assert amount < price
  assert min_offer is null or amount >= min_offer
  assert note passes contact_filter
  cancel active offers by buyer on (listing, variant)
  if auto_accept and amount >= auto_accept:
    return accept_and_create_order(...)
  insert offer status=pending expires_at=now_hkt()+48h

accept_offer(seller, offer):
  assert offer.pending or offer.countered
  in transaction:
    create_shop_order(price=offer.amount, fee=20%, escrow=existing)
    offer.status = accepted
  notify buyer → checkout

Acceptance criteria

  • Default listings: Buy Now only; no Make Offer CTA.
  • Seller can enable offers + optional min + optional auto-accept; min < list price validated.
  • Buyer can submit one active offer per listing (per variant); new offer cancels previous.
  • Seller Accept / Counter / Decline works; Accept creates one shop order at accepted price.
  • Order uses existing 20% + escrow; delivery_payload locked until paid/unlocked as Buy Now.
  • Public UI never shows other buyers’ offer amounts.
  • Offer notes with phone/WA/email rejected.
  • Pending offers expire after configured window (default 48h HKT).
  • No promo-code / BOGO / cart / auction countdown UI shipped.
  • [Parked · Epic] Creator-Monetized Study Vehicles — Gated Vaults · AI Mock Builder · Hybrid Tuition Bridge #474 memberships unaffected.
  • Labels on this issue remain enhancement + On Hold until CEO unparks.

Out of scope


Test plan

  1. Staging: publish a notes listing Buy Now HK$200, offers off → confirm no Make Offer.
  2. Enable offers, min HK$120, auto-accept HK$180.
  3. Buyer A offers HK$100 → rejected (below min).
  4. Buyer A offers HK$150 → pending; seller sees inbox; public PDP still no foreign amounts.
  5. Buyer A offers HK$160 again → prior 150 cancelled; one active.
  6. Seller counters HK$170 → buyer accepts → checkout at 170 → pay → escrow → unlock payload.
  7. Buyer B offers HK$185 → auto-accept → order at 185 → 20% fee base 185.
  8. Paste WA number in note → rejected.
  9. Wait/simulate 48h → expired offer not Acceptable.
  10. Confirm Promotions are a price edit, do not build a code #318: no promo code field appears on offer or checkout.

Open questions

  1. Seller response window: 48h HKT OK, or 24h / 72h?
  2. Timed auction later vs offer-only forever for V1.5 of this feature?
  3. Guest offers allowed, or login required (propose login)?
  4. Do freestyle gig listings (Epic: Freestyle micro-services / gig marketplace (extends notes shop) #325–329 family) get offers, or shop kinds only?
  5. Physical meetup / SF listings: same offer flow confirmed?
  6. Offer equal to list price: reject → Buy Now, or allow?
  7. Absolute floor (e.g. HK$1) when min unset?
  8. When seller turns offers off: expire pending immediately vs let timer run?
  9. Show anonymized “N people offered” on PDP? (propose no)
  10. Persist variant_id on offers before/after [Parked · Feature] Multi-format materials listing builder — cross-skill taxonomy, variants, upgrade paths #487 variants ship?

Activity

  1. owlstonbusiness-svg commented on Oct 27, 2026

    @owlstonbusiness-svg
    CollaboratorAuthor

    Bid pricing pack (sibling). Shop Make Offer surface is this issue. Near-full tutor scarcity bidding (parent/tutor matching, different persona + unlock) is #491. Related commercially, separate PRs — do not merge scopes. Both On Hold.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    On HoldExtra attention is neededenhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions