Repository navigation
Description
Header
- Phase: Parked / On Hold — Carousell-style Make Offer / bid pricing for shop materials. Does not ship until Notes Shop escrow (Notes shop, manual cashier, no off-platform contact #203 / # Issue D7 — Order, receipt, and dispute #269 / # Issue D8 — Shop rules #270 / [Feature / Notes Shop] Escrow Hold & Anti-Scam (Seller-Funded Refunds) #429) is boring and CEO unparks (with or after Materials Pack #487–#489).
- Must NOT block: [Urgent · V1] Unified account + roles + child sub-profiles + hours ledger #459, [V1 · #467] Scheduling + revenue/earnings engine (4 buckets) #461 / #461A, live Notes Shop Buy Now, Cap.57 / 1.5-lesson matching fee, or Materials Pack listing/search work when those unpark.
- Owner: Locate during impl (buyer PDP + seller Studio under
src/features/materials/*, shop order/cashier path). - Depends on / related (real links only):
- Materials listing formats / variants: #487 — Offers attach to a listing (and selected variant when variants exist). Do not dump offer UI into [Parked · Feature] Multi-format materials listing builder — cross-skill taxonomy, variants, upgrade paths #487 as required work.
- Buyer PDP / storefront: #488 —
Make OfferCTA placement on card/PDP. Cross-link only. - Seller insights: #489 — later may show offer conversion; not required for this issue.
- Cashier / money: #203, #269, #270, #429 — accepted offer still uses existing Notes Shop path: 20% platform fee + escrow hold. MUST NOT invent a second cashier.
- Promotions lock: #318 — promotions = price edit. MUST NOT build promo codes / BOGO / cart rules. Offer/bid ≠ promo code.
- Vehicles / memberships: #474 — out of scope for offers (gated vaults, not one-shot SKUs).
- Sibling (different surface): near-full tutor scarcity bidding — see sibling issue filed with this pack (parent/tutor matching, not shop).
- Architecture lock: Prefer extending
digital_materials+ existing order/checkout writers. MUST NOT invent a parallel shop, microservice, or auction microservice.
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_materialslisting (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 = trueclicks Make Offer / 出價. - URL: locate during impl (existing material detail + account seller inbox / offer desk).
Step-by-step flow
- Seller toggles
offers_enabled = true. - 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’sprice_hkdthe buyer is bidding on). - Seller optionally sets
auto_accept_hkd(≥min_offer_hkd, ≤ list price). Offers ≥ this threshold auto-Accept. - Buyer opens PDP → sees
Buy Now HK$X+Make OfferCTA (CTA hidden whenoffers_enabled = falseor listing sold-out / unpublished). - Buyer submits
offer_amount_hkd+ optional short note (≤ N chars; contact filter). Server stampscreated_atin HKT. - Rules on submit:
- MUST reject if
offer_amount_hkd < min_offer_hkd(when set) oroffer_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_idif variants): new submit cancels previous active offer from same buyer (idempotent; no duplicate actives).
- MUST reject if
- Seller inbox shows pending offers: Accept / Counter / Decline.
- Accept → create shop order at accepted price → buyer pays via existing cashier → escrow hold → unlock
delivery_payloadonly 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.
- Accept → create shop order at accepted price → buyer pays via existing cashier → escrow hold → unlock
- 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). - 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)
- MUST keep Buy Now always available when stock/listing allows (offers do not remove fixed price).
- MUST use existing Notes Shop order + escrow writers on Accept — no parallel payment path.
- MUST apply 20% on accepted price paid (same as markdown sales under Promotions are a price edit, do not build a code #318 spirit).
- MUST filter offer notes for phone / WhatsApp / email / “message me” patterns (reuse Shop description contact filter — locate during impl).
- MUST be idempotent: double-click Accept creates one order; double-submit offer collapses to one active.
- MUST NOT apply to [Parked · Epic] Creator-Monetized Study Vehicles — Gated Vaults · AI Mock Builder · Hybrid Tuition Bridge #474 Vehicles / memberships.
- MUST NOT build promo codes, BOGO, cart rules, or strike-through “was” engine (Promotions are a price edit, do not build a code #318).
- MUST NOT leak other buyers’ offer amounts on public surfaces.
- MUST NOT unlock digital/physical delivery instructions before payment/escrow rules say so.
- MUST NOT invent timed public auction in this issue.
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
- 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: onepending|counteredper(listing_id, variant_id, buyer_id). - Avoid stuffing concurrent offers into listing JSONB unless volume is proven tiny — indexes/expiry jobs are harder.
- Prefer table
- Listing columns on
digital_materials(or settings JSON already used by editors):offers_enabled bool default false,min_offer_hkd,auto_accept_hkd. - 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). - Buyer UI: material PDP / card Buy CTA component (locate under shop/materials public routes).
- Seller inbox: account route for offers (locate; may sit beside existing orders desk).
- Accept → order: call existing Notes Shop order creator with
price_paid = accepted_amount; fee 20%; escrow unchanged; passoffer_idfor audit. - Jobs: HKT expiry sweeper every N minutes for
pending/counteredpastexpires_at. - Contact filter: reuse Shop description sanitizer on
note. - 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_payloadlocked 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 Holduntil CEO unparks.
Out of scope
- Timed public auctions / countdown snipes.
- Promo codes, BOGO, multi-note cart, strike-through engine (Promotions are a price edit, do not build a code #318).
- Vehicles / memberships ([Parked · Epic] Creator-Monetized Study Vehicles — Gated Vaults · AI Mock Builder · Hybrid Tuition Bridge #474).
- DRM native video, SF API waybills, paid boost CPC.
- Tutor scarcity lesson-rate bidding (sibling issue — matching surface).
- Changing Cap.57 / 1.5-lesson matching fee.
- Building Materials Pack [Parked · Feature] Multi-format materials listing builder — cross-skill taxonomy, variants, upgrade paths #487–[Parked · Feature] Seller Studio insights (views/likes/sales) & zero-effort promo toolkit #489 themselves.
Test plan
- Staging: publish a notes listing Buy Now HK$200, offers off → confirm no Make Offer.
- Enable offers, min HK$120, auto-accept HK$180.
- Buyer A offers HK$100 → rejected (below min).
- Buyer A offers HK$150 → pending; seller sees inbox; public PDP still no foreign amounts.
- Buyer A offers HK$160 again → prior 150 cancelled; one active.
- Seller counters HK$170 → buyer accepts → checkout at 170 → pay → escrow → unlock payload.
- Buyer B offers HK$185 → auto-accept → order at 185 → 20% fee base 185.
- Paste WA number in note → rejected.
- Wait/simulate 48h → expired offer not Acceptable.
- Confirm Promotions are a price edit, do not build a code #318: no promo code field appears on offer or checkout.
Open questions
- Seller response window: 48h HKT OK, or 24h / 72h?
- Timed auction later vs offer-only forever for V1.5 of this feature?
- Guest offers allowed, or login required (propose login)?
- Do freestyle
giglistings (Epic: Freestyle micro-services / gig marketplace (extends notes shop) #325–329 family) get offers, or shop kinds only? - Physical meetup / SF listings: same offer flow confirmed?
- Offer equal to list price: reject → Buy Now, or allow?
- Absolute floor (e.g. HK$1) when min unset?
- When seller turns offers off: expire pending immediately vs let timer run?
- Show anonymized “N people offered” on PDP? (propose no)
- Persist
variant_idon offers before/after [Parked · Feature] Multi-format materials listing builder — cross-skill taxonomy, variants, upgrade paths #487 variants ship?
Activity
owlstonbusiness-svg commentedon Oct 27, 2026
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.