Skip to main content

Bidding (Price Negotiation) Live

Structured price negotiation with FIFO seller queue, per-buyer price isolation, in-app messaging, and locked-price checkout. Off by default — sellers opt in per store, optionally per product. The behavioural-unlock feature that distinguishes KA26 from Zepto/Blinkit/BigBasket: real local commerce is a conversation, not a take-it-or-leave-it transaction.

1. Overview

Pricing in semi-urban + rural India is negotiated. A buyer asks "Are these mangoes sweet?" and "Can you do ₹350?", the seller responds, a deal is reached. This module brings that entire interaction into the app — without forcing every store onto a chat-heavy UX.

ConceptWhat it means
BidA buyer's price offer on a specific product. Lifecycle: queued → pending → countered → accepted → converted.
CounterSeller's response with a different price. Buyer can accept-counter, counter back, or walk away.
LockWhen a bid is accepted, the agreed price is locked for that buyer for 1 hour — they have time to checkout.
QueueWhen a seller already has 10 active negotiations, the 11th+ buyer waits in FIFO line. Invisible to seller until promoted.
Per-buyer isolationBuyer A's locked ₹320 and Buyer B's locked ₹380 on the same product coexist independently. Other buyers' prices are structurally invisible.

2. Limits + rules (single source of truth)

These constants live in src/lib/negotiation.ts and are mirrored to clients via the engine library. Changing them requires a code change.

ConstantDefaultMeaning
MAX_ACTIVE_OFFERS_PER_BUYER5A buyer can have at most 5 concurrent active bids across the entire platform. 6th attempt → 429 max_active_reached.
MAX_SELLER_ACTIVE_OFFERS10A seller is engaged in at most 10 bids (pending+countered+accepted). 11th onward goes to queue.
negotiationFloorPercent70 (per-store, configurable)Server auto-rejects offers below ceil(asking × floor%) without burning a round. Seller-configurable 0–100.
negotiationMaxRounds3 (per-store, configurable)Max seller-touched rounds. Auto-rejects don't count. Seller-configurable 1–10.
ACTIVE_OFFER_TTL_MS24hInactive bid auto-expires. Reset on queue promotion.
ACCEPTED_LOCK_TTL_MS1hLock window after acceptance. Buyer must checkout within this.
QUEUED_OFFER_TTL_MS48hBuyer can wait in queue this long before auto-expire.

3. Per-buyer price isolation invariant

The defining invariant. For any (buyerId, productId):

At most one row exists in a non-terminal-for-new-offer status (queued | pending | countered | accepted). Other buyers' offers on the same product live as different rows under different buyerId and are STRUCTURALLY invisible — the WHERE clause filters by buyerId = me.

Two buyers can simultaneously hold accepted locks at different prices on the same Mango. Each one's checkout charges THEIR locked price. Neither sees the other.

Enforcement points:

  • Schema: (buyerId, productId) natural scope; FK structure prevents cross-buyer reads.
  • Create-offer: existingActiveOfferOnProduct pre-check blocks duplicates within scope.
  • List APIs: every query is WHERE buyerId = me for buyer view.
  • State machine: applyAction() requires actor matches; cross-buyer transitions impossible.
  • Order placement: server validates offer.buyerId === currentUser.id AND offer.productId === item.productId AND offer.status === 'accepted' AND lockedUntil > NOW() before honoring priceOfferId.

4. Buyer experience

4.1 Discoverability — "Make a bid" CTA

On any product detail page, IF the store has bidding enabled (and the product hasn't explicitly opted out), the buyer sees a "Make a bid" button below the price. The hint reads "Seller accepts bids · Min ₹X" where X is the floor.

If the store has bidding off (or product override is false), the button is HIDDEN entirely — the buyer cannot even attempt to bid. They see only the asking price.

4.2 Composing a bid

Tap "Make a bid" → slide-up modal with:

  • Asking price reminder ("Asha's asking price is ₹400. Bids below ₹280 are auto-declined.")
  • Number input pre-filled at midpoint between floor and asking (anchors the buyer to a sensible amount)
  • "Send bid · ₹X" CTA

Validation happens server-side. If below floor → friendly alert "Too low. Use ₹X". If buyer at 5-cap → "You have 5 ongoing negotiations. Wrap one up first."

4.3 The negotiation card (3 visual states)

After bid is sent, the product page shows a NegotiationCard component (replaces the Make-a-bid CTA) with:

State A — QUEUED: amber banner. "Asha is busy. You're #3 in queue. Your bid of ₹X goes live as soon as they're free." Withdraw option visible.

State B — ACTIVE/COUNTERED: card shows the price ladder (asking → your offer → seller's counter). Action buttons:

  • If seller-turn (waiting on seller): "Waiting on seller · 23h 12m left" + Withdraw option.
  • If buyer-turn (seller countered): big green "Accept ₹X" + Counter button + Walk away.

State C — ACCEPTED: green card with prominent "Take this deal · ₹X" button. Live mm:ss countdown. Quantity stepper (1..99) with running total. One tap → cart pre-attached + checkout sheet auto-opens.

4.4 Inline message thread

Every NegotiationCard has a scrollable message thread + send-text input below the price ladder. Shows:

  • System events (centered grey pills): "Bid placed at ₹300", "Counter offered ₹350", "Deal closed at ₹350"
  • Buyer bubbles (right-aligned, primary colour)
  • Seller bubbles (left-aligned, white with border)

Buyer can ask "Are these fresh?" and the seller responds in their inbox. Both sides see the same thread chronologically. Push notification on new text from the other side.

4.5 My Bids screen (Profile tab → My Bids)

Surfaces every bid the buyer has placed:

ACTIVE section: queued + pending + countered + accepted. Yellow border on rows where it's the buyer's turn. Lock-countdown visible on accepted rows ("Lock expires in 47m — tap to take the deal").

RECENT section (last 30 days): converted/rejected/withdrawn/expired. Status reasons:

  • converted → "Bought at ₹X"
  • rejected (manual) → "Declined"
  • rejected (auto-floor) → "Below seller's floor"
  • withdrawn → "You withdrew"
  • expired → "Expired (no response in time)"

Tap any row → product page with NegotiationCard already showing the right state.

4.6 Take-this-deal checkout

After acceptance, the buyer:

  1. Taps "Take this deal · ₹X" on the NegotiationCard
  2. App routes to /store/[id]?openCheckout=1 which auto-opens the checkout sheet
  3. Cart line is pre-attached with priceOfferId so server uses lockedPrice (not asking price)
  4. Buyer fills delivery address + payment method + confirms
  5. On POST /api/store-orders, server: validates the offer is still accepted + lock not expired + buyer matches + product matches → creates order at locked price → flips offer to converted → promotes next queued offer for that seller

If lock has expired before checkout → 410 "the price lock expired. Make a new offer." Bid stays as accepted but cannot be used.

If buyer changes their mind → walk away, no penalty.

5. Seller experience

5.1 Opt in (per-store)

Store edit screen → "Allow price negotiation" toggle. Off by default. When enabled, two more inputs reveal:

  • Auto-decline below (% of asking) — default 70. Server-side floor; buyers below this get instant rejection.
  • Max negotiation rounds — default 3. How many seller-touched rounds before negotiation closes.

Store-level enabled means: ALL products in this store accept bids by default. Sellers can override per-product.

5.2 Per-product quick-toggle (Phase 2.5)

On the seller's store products list, every product card has a tappable chip:

  • Green "Bidding" chip — bidding allowed for this product (effective state)
  • Grey "No bid" chip — bidding disabled

One tap flips it. Toast confirms ("Bidding ON for Mango"). Chip reflects per-product override OR store-wide flag fallback.

Use case: store has bidding ON globally, but for today's loss-leader Mango (heat-wave demand spike) the seller wants fixed-price only. One tap, no form, no save button.

The toggle always sets explicit true/false — never null. So once a seller has touched it, the "inherit store" semantic is replaced by an explicit choice for that product.

5.3 Bids inbox (More tab → Bids)

Shows the seller's 10 engaged bids + queue summary:

Capacity banner at top: "3 of 10 active · 4 waiting" — turns red at cap with hint to close one to free a slot.

Active bids grouped by product. Each card shows:

  • Buyer name + order count ("Shiva, 12 prior orders")
  • Price ladder (asking → buyer offered → your counter if any → locked if accepted)
  • Status text ("Your turn" yellow / "Waiting · 22h left" / "Locked · 47m")
  • Inline action buttons (Accept / Counter / Reject) when seller's turn
  • Counter modal pre-fills the halfway suggestion round((asking + offered) / 2) for one-tap closing

Queue preview at bottom: count + first 3 names of buyers waiting outside, READ-ONLY (matches the "shop is busy" model — seller cannot interact with queued buyers).

30-second auto-refresh so promoted-from-queue + new offers land without pull-to-refresh.

5.4 Inline message thread per bid

Each active bid row has a collapsible "Open messages" toggle. Loads the thread + lets the seller reply inline. Same thread the buyer sees on their NegotiationCard. Push notification on new buyer messages.

  • Push notification "New bid: Asha bid ₹320 on Mango" → tap → seller lands directly on /offers?focus=N with that bid scrolled into view + message thread auto-opened.
  • Push "Buyer countered with ₹X" → same deep link.
  • Push "Counter accepted!" → same deep link with the now-accepted bid in the lock-countdown state.

6. State machine

┌──────────┐
buyer ───→ │ queued │ (only if seller at 10-cap)
└────┬─────┘
│ promoted (FIFO, when seller closes one)

┌──────────┐
│ pending │ (seller's turn)
└────┬─────┘
┌──────────┼─────────┬───────────────┐
▼ ▼ ▼ ▼
accepted countered rejected withdrawn
│ │ (seller declines) (buyer)
│ │ buyer's turn
│ ├──→ accepted
│ ├──→ counter_buyer ──→ [new pending row]
│ └──→ withdrawn

lock starts (1h) ──→ converted (used in order)
└──→ expired (lock TTL hit, no order)

(any active state) ──→ expired (24h inactive timeout)

Auto-rejects (below floor) DON'T burn an attempt — they return synchronously with rejectedReason='auto_floor' so buyer can immediately try again.

7. Schema

Store (negotiation columns added 2026-04-26)

negotiationEnabled Boolean @default(false)
negotiationFloorPercent Int @default(70)
negotiationMaxRounds Int @default(3)

Product (per-product override)

negotiationEnabled Boolean? // null = inherit store, true/false = explicit

PriceOffer (the bid thread row)

model PriceOffer {
id Int @id @default(autoincrement())
productId Int
storeId Int
buyerId Int // Consumer.id
sellerId Int // Seller.id (denormalised)
askingPriceSnapshot Decimal // frozen at creation — price drifts can't mutate history
offeredPrice Decimal
counterPrice Decimal? // seller's counter when status='countered'
lockedPrice Decimal? // set on acceptance (counterPrice if buyer accepted; offeredPrice if seller accepted)
status String // queued | pending | countered | accepted | converted | rejected | withdrawn | expired
parentOfferId Int? // chain back to previous round
attemptNumber Int @default(1)
expiresAt DateTime // 24h for active, 48h for queued
lockedUntil DateTime? // 1h after acceptance
optimisticVersion Int @default(0) // concurrency guard
acceptedAt DateTime?
convertedOrderId Int?
rejectedReason String? // auto_floor | manual | max_rounds | expired
awaitingActionFrom String // buyer | seller | none
queuedAt DateTime?
promotedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([buyerId, status])
@@index([sellerId, status])
@@index([sellerId, status, queuedAt]) // FIFO promotion query
}

BidMessage (thread)

model BidMessage {
id Int @id @default(autoincrement())
priceOfferId Int
senderType String // "buyer" | "seller" | "system"
senderId Int?
text String?
systemKind String? // bid_placed | bid_countered | bid_accepted | bid_rejected | bid_withdrawn | bid_promoted
data Json?
isReadByBuyer Boolean @default(false)
isReadBySeller Boolean @default(false)
createdAt DateTime @default(now())
}

StoreOrderItem (order linkage)

priceOfferId Int?
priceOffer PriceOffer? @relation(...)
negotiatedFromPrice Decimal?

When priceOfferId is set: price carries the locked price (NOT product.price). negotiatedFromPrice is the asking-price snapshot for invoice traceability.

8. APIs

Method + PathPurpose
POST /api/products/[id]/offersBuyer creates. Returns {offer, placement: go_active | go_queued}. Below-floor returns 400 with {floor, offered}.
PATCH /api/offers/[id]Single endpoint for accept/reject/counter/counter_buyer/withdraw. Auto-classifies caller (buyer vs seller). Optimistic-version conflict → 409.
GET /api/offersBuyer's offers. Default = active only. ?include=history adds last-30d terminal rows. ?productId=N scopes to one.
GET /api/offers/[id]/messagesFull thread (text + auto-injected system events). Auto-marks-read for the side reading.
POST /api/offers/[id]/messagesAppend free-text message (1000-char cap). Returns 410 once bid is in terminal-closed state.
GET /api/seller/offersSeller inbox: 10 engaged + queue summary + first 3 queued names (read-only).
POST /api/store-ordersNow accepts priceOfferId per item. Validates ownership/status/lock; uses lockedPrice; flips offer to converted; promotes seller queue.

9. Push notifications

Every state transition creates BOTH an in-app Notification (consumer bell) / SellerNotification (seller bell) row AND fires native push with path for deep linking.

TriggerRecipientnotifTypePath
New bid arrivesSellerbid_new/offers?focus=N
Buyer counters seller's counterSellerbid_buyer_counter/offers?focus=N
Buyer accepts seller's counterSellerbid_counter_accepted/offers?focus=N
Buyer withdrawsSellerbid_withdrawn/offers?focus=N
New text message from buyerSellerbid_message/offers?focus=N
Seller acceptsBuyerbid_accepted/product/[id]
Seller countersBuyerbid_countered/product/[id]
Seller rejectsBuyerbid_rejected/product/[id]
Queue promotionBuyer (just promoted)bid_promoted/product/[id]
New text message from sellerBuyerbid_message/product/[id]

10. Mobile UI surfaces

Seller (mobile-seller)

FilePurpose
mobile-seller/app/store/edit/[id].tsxPer-store toggle + floor + max-rounds inputs
mobile-seller/app/store/[id].tsxPer-product quick-toggle chip on each product card
mobile-seller/app/offers/index.tsxBids inbox with capacity banner + queue summary + inline message threads
mobile-seller/app/(tabs)/more.tsx"Bids" entry in More tab

Consumer (mobile)

FilePurpose
mobile/src/components/NegotiationCard.tsxMake-a-bid CTA + 3 visual states + inline thread + take-this-deal CTA on accepted
mobile/app/my-bids.tsxHistory screen: Active + Recent (30d) sections
mobile/app/(tabs)/profile.tsx"My Bids" entry below "My Ads"
mobile/app/store/[id].tsxHonors ?openCheckout=1 deep link from take-this-deal flow

Server-side libraries

FilePurpose
src/lib/negotiation.tsPure-function state machine + validators (38 unit tests pin every branch)
src/lib/offer-promotion.tsFIFO queue promotion (called from PATCH route + order placement)
src/lib/bid-messages.tsSystem-event injection + thread-walk helper

11. Tests

TestCoverage
tests/negotiation-engine.test.ts38 pure-function unit tests for state machine + validators + queue placement
tests/negotiation_e2e.pyPhase 1: 26 live API scenarios (parallel bids, isolation, cap, floor, accept, counter, locked-price checkout)
tests/negotiation_e2e_phase2.pyPhase 2: 20 scenarios (messaging both directions, system events, terminal-closed 410, per-product override)
tests/comprehensive_negotiation_e2e.py23 scenarios across 3 real accounts simulating two phones at once
tests/phase25_e2e.py11 scenarios for per-product toggle + history mode
tests/push-payload-structure.test.ts6 contract tests pinning every push has path + notifType matching router cases
tests/no-hooks-after-early-return.test.tsStatic guard for the SellerOffersScreen crash class
mobile-seller/__tests__/components/SellerOffersScreen.test.tsx4 render tests (loading, empty, populated, queue preview)
mobile-seller/__tests__/components/StoreDetailBidToggle.test.tsx4 render tests (3 negotiation states + tap fires PUT)
mobile-seller/__tests__/components/EditStoreScreen.test.tsx4 render tests (negotiation OFF, ON, service-area-both)
mobile-seller/__tests__/components/ProductFormBidding.test.tsx4 render tests (3-way picker mounts in create + edit)
mobile/__tests__/unit/NegotiationCard.test.tsx6 render tests (every visual state + thread bubbles)
mobile/__tests__/unit/MyBidsScreen.test.tsx5 render tests (loading, empty, active, recent, auto-floor)
mobile/__tests__/unit/ProductDetailNegotiation.test.tsx5 render tests (anonymous, disabled, floor math, max rounds)

12. Phase history

DatePhaseWhat landed
2026-04-261A-JSchema + APIs + state machine + state machine tests + mobile UIs (per-store toggle, NegotiationCard, Offers inbox); 26 live E2E scenarios
2026-04-262BidMessage table + thread APIs + system-event auto-injection + take-this-deal CTA + notification deep links + per-product PUT support; 20 live E2E scenarios
2026-04-262.5Per-product quick-toggle on store list + buyer's My Bids screen + history API mode; 11 live E2E scenarios
2026-04-272.68 device-test bug fixes — see §14 below
next3Voice notes (Opus codec, server-side STT for moderation), photo attachments, Q&A separate from bidding (works even with bidding off)

13. Anti-patterns documented (don't repeat these)

  • No silent fallback to asking price at checkout when priceOfferId validation fails. Buyer would be charged more than expected. Guard fails the line with 4xx.
  • No best-effort floor enforcement — server is the SINGLE source of truth. Client validation is for UX only.
  • No row created for auto-rejects — DB stays clean; buyer doesn't burn a round on hitting the floor.
  • No counter ≤ buyer's offer — forces seller to either accept or counter strictly higher (preserves the haggling semantic).
  • No seller visibility of queued offers — preserves the "shop is busy" abstraction; seller never feels overwhelmed by 50-deep queue.
  • No client-side price recompute at order placement — server reads from lockedPrice every time.
  • No null writes from the per-product quick-toggle — once a seller has touched a product's bidding state, the "inherit" semantic is replaced by an explicit choice. The 3-way picker in the full Edit Product form remains the only way to revert to inherit.

14. Phase 2.6 bug bash (2026-04-27)

After Phases 1, 2, 2.5 shipped 2026-04-26, device-testing the live APKs surfaced 8 bugs. All fixed + redeployed in one push. Two additional bugs were uncovered by tests/bidding-live-e2e.test.ts against production with the 3 shared accounts.

The bugs

#BugRoot causeFix
1Seller "Counter offer" modal — Send button hiddenAbsolute-positioned KeyboardAvoidingView overlapped by underlying message-thread inputWrapped in RN <Modal>, rearranged Send (primary, flex:1) + Cancel (ghost, trailing) with stable testIDs
2Order Summary showed listed price (₹150) not negotiated (₹130)describeCartLine + cartSubtotal only read c.product.price — server was correct, client was wrongAdded lockedPrice?: number to StoreCartItem, denormalized at take-this-deal time, threaded through cart persistence + display
3qty × negotiated price math wrongSame root cause as #2Same fix — formula is unitPrice * c.quantity, qty multiplication just falls out
4Lock-expired branch had no action buttonBanner text onlyProminent red "Place a new bid" CTA that opens existing ComposeBidModal
5Round counter buried (small grey caption)11px grey text next to headerColored pill: indigo (early) → amber (penultimate) → red (final round). Format "Round 1/3"
6Product save returned "Request Failed"/api/products/[id] only exported PUT, GET, DELETE. mobile-seller calls api.patch from 3 places (edit form, status toggle, back-in-stock undo) → 405export const PATCH = PUT; alias + new tests/api-method-coverage.test.ts static guard
7Stale accepted offers blocked new bidsaccepted is non-terminal-for-new-offers (lock window contract); after lock expires the row sits forever, blocks new bids with 409, buyer can't withdraw from acceptedLazy-expire at top of POST /api/products/[id]/offers: atomic updateMany flips (accepted, lockedUntil < now, convertedOrderId null)expired. Idempotent
8Variable-measurement (kg) products ignored lockedPricedescribeCartLine only fixed the fixed-price path — measurement products flow through calculateLinePrice(price: ...)Mirror server logic: basePrice = c.priceOfferId && lockedPrice ? lockedPrice : product.price

Static guards added

  • tests/no-cart-line-without-locked-price.test.ts — verifies (a) describeCartLine + cartSubtotal fork on priceOfferId && lockedPrice, (b) onTakeDeal denormalizes lockedPrice into the cart line, (c) cart persistence writer carries it.
  • tests/bidding-lazy-expire.test.ts — pins the 4 updateMany predicates against drift + asserts lazy-expire runs BEFORE the existing-active check.
  • tests/api-method-coverage.test.ts — walks every api.METHOD('/api/...') call in mobile-seller/ (literal AND template-literal paths), resolves to the matching route.ts, asserts the route exports that method.

Live E2E

  • tests/bidding-live-e2e.test.ts (BIDDING_LIVE_E2E=1 to run) — 4 cases against ka26.shop with 3 real accounts: PATCH alias reachable, per-buyer isolation verified, seller inbox shows distinct cards, server-computed StoreOrder.total = lockedPrice * qty (not product.price * qty).

What this proved end-to-end

Pineapple listed at ₹150 → buyer A bid ₹110 → server returned 201 with offer id 26 → buyer accepted → server returned the StoreOrder with subtotal: "112", total: "112", negotiatedFromPrice: "150". Server-side math + per-buyer isolation invariant + lockedPrice propagation all confirmed correct on production.


15. Phase 2.7 — Bid screen overhaul (2026-04-29 → 2026-05-01)

Two days of tester-reported bugs + UX improvements. Shipped as APK seller v1.0.2 (versionCode 3) and consumer v1.0.4 (versionCode 24), plus 4 Cloud Run deploys.

BUG-001 (consumer checkout sheet — vibrating modal)

The consumer checkout <Modal> jittered every time the user focused Name / Phone / Address inputs on Android. Two layout systems competed: AndroidManifest's windowSoftInputMode="adjustResize" AND a <KeyboardAvoidingView behavior="height"> wrapper. Compounded by maxHeight: "90%" (percentage) on the sheet, which recomputed per layout pass.

Fix: KeyboardAvoidingView is now enabled={Platform.OS === "ios"} only on Android. iOS path unchanged. maxHeight is now an absolute pixel value computed once from Dimensions.get("window").height * 0.85.

Anti-pattern rule in BUGS-FIXED.md: "Never stack KeyboardAvoidingView on top of windowSoftInputMode=adjustResize on Android". Same pattern fix applied to BUG-006d (seller counter sheet) below.

BUG-006a — stale pending/countered bids stayed in seller's active inbox

getPersonalizedStores() had no search parameter and silently no-op'd on filtering. Same class of bug appeared in /api/seller/offers GET — stale pending/countered bids (expiresAt < now but status not yet flipped) showed in the active count as "Waiting · expired".

Fix: added an idempotent prisma.priceOffer.updateMany at the top of GET that flips them to expired. Scoped to THIS seller. Same fix mirrored on the buyer side at /api/offers GET.

BUG-006c — Reject button clipped off the right edge

Action buttons used natural width based on label text ("Accept ₹100" + "Counter ₹X" + "Reject" overflowed narrow phones).

Fix: weighted flex (Accept:2, Counter:2, Reject:1), all 3 with bordered variant. Labels shortened (price already shown in the price ladder above).

BUG-006d — Counter sheet's Send button hidden behind keyboard

Same root cause as BUG-001 but on the seller bid screen.

Fix: same KeyboardAvoidingView enabled={Platform.OS === "ios"} pattern. Plus the bottom sheet got statusBarTranslucent + paddingBottom.

BUG-006e — bid_accepted/countered/rejected analytics never fired

Only bid_placed events ever fired. Live test against prod showed 3 bid_placed events for 3 buyer POSTs and ZERO events for the seller counter + buyer accept that completed the negotiation. Root cause: track() was called in POST /api/products/[id]/offers but the PATCH handler at /api/offers/[id] only created system-message bubbles, never called track(). Negotiation activity under-reported in /admin/analytics since launch.

Fix: added track() call in the PATCH handler keyed on (action.kind, actor):

action.kindactoreventType
acceptsellerbid_accepted_seller
acceptbuyerbid_accepted_buyer
countereitherbid_countered
counter_buyerbuyerbid_placed (new chained offer)
rejectsellerbid_rejected

Props include price, attemptNumber, askingPrice, buyerId, sellerId, productId for full analytics queries.

Live verified with the canonical place → counter → accept sequence against prod — all 3 events appeared in UserEvent.

Expired Bids "Follow up" section

Tester ask: "do we show expired bids to seller? so he can contact the buyer and follow up." Added a collapsible "Expired bids · follow up" section on the seller bid screen, fetched on demand via ?include=expired.

Each expired card shows:

  • Buyer name
  • What they offered + on what product (asking price for context)
  • Absolute timestamp ("28 Apr, 21:53") — not relative ("3d ago")
  • Call button (tel: deep link, phone number passed internally — NOT shown in label for privacy + readability)
  • WhatsApp button (https://wa.me/91...?text=Hi+...& with pre-filled "Hi {buyer}, you bid ₹X on {product} but it expired. Still interested? Let's talk.")
  • Delete button (soft-delete — see below)

Default-collapsed to keep active inbox clean.

Completed Deals section

Tester ask: "Where do successful bids go? They are not visible in current UI." Added another collapsible "Completed deals" section showing the last 30 days of status='converted' bids — closed deals that became actual orders. Each card shows buyer + product + locked price + closed-at timestamp.

Fetched via ?include=completed.

DELETE /api/seller/offers/[id]

New endpoint. Allows the seller to dismiss an expired bid from their follow-up list.

BehaviorWhy
Soft-delete via awaitingActionFrom = "dismissed"Preserves the row for analytics (acceptance rate, response time, total bids received)
State guard: only status === "expired" rowsActive rows (pending/countered/accepted) MUST go through the proper PATCH state machine
Cross-seller protection: 404 instead of 403Anti-enumeration — a malicious seller can't probe offer IDs
Idempotent on already-dismissedReturns { ok: true, alreadyDismissed: true }
Dismissed rows filtered out of GET expired listKeeps follow-up section tidy

BUG-007 — stale ACCEPTED bids stayed in active count

Tester screenshot showed "Apple (test)" bid as "Locked · expired" but COUNTED in 1/10 active. Different from BUG-006a — those were stale pending/countered. THIS bug is stale ACCEPTED bids whose lockedUntil passed but convertedOrderId is still null (buyer never checked out within the 1-hour lock).

The existing Bug 7 lazy-expire (in POST /api/products/[id]/offers) handles this case but only when a buyer places a NEW bid. A seller who hasn't received a new bid in days kept seeing stale-accepted in their active count.

Fix: SECOND prisma.priceOffer.updateMany call (in addition to the BUG-006a one) at the top of both /api/seller/offers and /api/offers GET:

prisma.priceOffer.updateMany({
where: {
sellerId: seller.sellerId,
status: "accepted",
lockedUntil: { lt: now },
convertedOrderId: null,
},
data: { status: "expired", awaitingActionFrom: "none", updatedAt: now },
});

Both updates run in Promise.all for performance.

Bid screen i18n (Kannada + Hindi)

Pre-fix every visible string on the seller bid screen was hardcoded English. KA26's primary market is Kannada-speaking Gadag — sellers were reading their most-used screen in a non-native language.

Added 41 → 54 bids.* translation keys covering the full screen (header, status pills, price ladder, action buttons, decline confirmation, counter sheet, expired/completed/queue sections, Call/WhatsApp/Delete CTAs, re-engage WhatsApp message, empty state).

3 locales filled in fully: EN / KN / HI. Other 4 (MR/TA/TE/ML) auto-fall back to EN via LanguageContext line 64 — they'll get real translations later.

Counter button truncation fix recap (UI tester report 2026-05-01)

Even with flex: 1 wrappers from BUG-006c, the "Counter" label still truncated to "Count..." on narrow phones. Root cause was visual imbalance — Reject used variant="ghost" (text-only, no border) which made it visually take less room and pushed the others' rendering boundary.

Final fix: all 3 buttons now variant="primary" (Accept) or variant="secondary" (Counter, Reject — bordered). Weighted flex 2:2:1 so Accept and Counter get more room than Reject (the rare action).

Phone number privacy on Call button

Was: "Call 919901012421" (visually crowded, leaked the full number, clipped on narrow screens). Now: just "Call" — phone is passed to tel: internally on tap. Same for WhatsApp. Tester-suggested for privacy + readability + no clipping.

Absolute timestamps replace relative time

Was: "Expired 3d ago" / "Expired 2h ago". Now: formatBidTimestamp(iso) returns "28 Apr, 21:53" (current year) or "28 Apr 2025, 21:53" (other years). Negotiation context needs precision; relative time is misleading.

Tests added (Phase 2.7)

Spread across tests/seller-bid-lifecycle.test.ts (now 68 tests). Coverage:

  • 6 tests pin BUG-006a + BUG-007 lazy-expire (both endpoints, scoping, accepted handling, post-expiry capacity math)
  • 5 tests pin Counter/Reject action row weighted flex + all-bordered
  • 5 tests pin Counter sheet KAV iOS-only + balanced flex + short Send label + paddingBottom
  • 7 tests pin DELETE endpoint (auth, cross-seller protection, state guard, soft-delete, idempotent, filter from expired list)
  • 4 tests pin Completed section (API, capacity, UI section, empty state)
  • 9 tests pin i18n wiring (all 3 locales have all keys, screen uses t())
  • 5 tests pin BUG-006e analytics fire on PATCH

Live verification on prod ran a 9-step e2e covering: BUG-007 stale-accepted flip, capacity reflects post-expiry count, ?include=expired,completed returns both arrays, DELETE happy/idempotent/state-guard/cross-seller, dismissed bid filtered from list. All 9 passed.

APIs (updated surface, 2026-05-01)

GET /api/seller/offers
GET /api/seller/offers?include=expired,completed
DELETE /api/seller/offers/{id} ← new
GET /api/offers
GET /api/offers?include=history
GET /api/offers?productId={id}
POST /api/products/{id}/offers
PATCH /api/offers/{id} ← now fires bid_* analytics
PUT /api/offers/{id} ← deprecated alias for PATCH
GET /api/offers/{id}/messages
POST /api/offers/{id}/messages