Skip to content

Commit ce2c195

Browse files
gitchaddgitchadd
andauthored
fix(widgets): modal contrast, contact-support clickability, BUY/PAID status tiers (p2pdotme#6)
* fix(modal): pipe themeStyle through portal so card respects host palette Modal portals to document.body, escaping the calling widget's root where `themeToCssVars(theme)` is applied. The card's `background: var(--p2p-color-bg, #ffffff)` fell back to white, while inner content (which reaches the host's CSS vars via the inner themeStyle div) renders text in near-white — producing white-on-white text on dark integrators. Each Modal caller now passes its `themeStyle` to Modal, which spreads it on the backdrop. CSS cascade carries the vars to the card so the surface matches the integrator's palette. Also pins `color: color.text` on the card so descendants without an explicit color inherit a readable token. Verified on merchant-app demo: dialog bg now resolves to integrator dark surface, text near-white — proper contrast restored. * fix(contact-support): always render the button so users always have a recourse Previous behavior only rendered the Contact Support chip when the order was inside the dispute window or after a dispute was filed. PLACED / ACCEPTED / PAID / COMPLETED states showed status text with NO action — leaving users with no way to contact support when an order was stuck (e.g. PAID waiting on a merchant-confirm-completion watcher that hasn't fired). Now every row renders a Contact Support button. Variant depends on state: - In dispute window → chip with countdown, click opens report flow - Dispute open / resolved → plain button with dot, click opens chat - Everything else → plain button, click opens chat (or surfaces "Support not available yet" if the order isn't bound to an inbox yet) End users always have a way to reach support, even when no on-chain dispute path is currently valid. * Revert "fix(contact-support): always render the button so users always have a recourse" This reverts commit f57f0bf. * fix(contact-support): in-window chip is always clickable, falls back to chat when no txSigner Previous behavior: the in-window report chip rendered with disabled={!txSigner}. When the embedder didn't wire txSigner (or it was somehow undefined), the chip appeared visibly greyed out and the HTML disabled attribute blocked the click — leaving the user with a visible but non-functional button. Now: the chip is always clickable. Click target depends on txSigner: - txSigner present → opens the on-chain report flow (raiseDispute) - txSigner missing → opens chat (will surface "Support not available yet" if the bridge has no inbox bound, but the user is never left without a response on click) * fix(order-action): surface stale-PLACED + processing-payment countdown Two related smart-action machine improvements: 1. **PLACED past 5min → "still matching, taking longer than usual"** — the chain's status field stays PLACED until an `autoCancelExpiredOrders` keeper sweep runs, which lags the actual accept-window expiry. The widget now surfaces the staleness so users don't keep staring at "Placed · matching" forever when no merchant can pick the order up anymore. 2. **BUY/PAID renders a countdown** — "Paid · processing payment · completes within <X>m" while inside the 30-min processing window (matches contract's placedAt + orderExpiry envelope). Past the window we fall back to the plain "Paid · processing payment" label. Constants exported: - `PLACED_STALE_THRESHOLD_MS = 5 * MIN` - `BUY_PAID_PROCESSING_WINDOW_MS = 30 * MIN` 71 node:test cases pass, 55 vitest pass, typecheck + build clean. * fix(order-action): three-tier BUY/PAID status (no countdown in middle band) Revise the BUY/PAID label tiers per UX spec: < 5min → "Paid · processing payment" 5–30min → "Paid · processing payment · taking longer than usual" ≥ 30min → "Paid · processing payment · will resolve within <countdown>" The middle tier is a gentle warning without a countdown (drops the noise of a constantly-changing timer for orders that are merely a few minutes overdue). The outer tier anchors the countdown on BUY_DISPUTE_CLOSE_MS (24h) — the chain's outermost resolution deadline, so the user always sees a hard upper bound on the wait. --------- Co-authored-by: gitchadd <gitchad@icloud.com>
1 parent 08e6bea commit ce2c195

7 files changed

Lines changed: 100 additions & 24 deletions

File tree

‎src/core/order-action.test.ts‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ test("placed: status only, no action", () => {
7676
assert.deepStrictEqual(out.action, { kind: "none" });
7777
});
7878

79+
test("placed: past staleness threshold surfaces 'taking longer'", () => {
80+
const out = computeOrderAction(
81+
baseOrder({ status: "placed" }),
82+
PLACED_AT_MS + 6 * 60 * 1000, // 6 minutes after placement, past 5-min threshold
83+
);
84+
assert.match(out.statusText, /taking longer than usual/);
85+
assert.deepStrictEqual(out.action, { kind: "none" });
86+
});
87+
7988
// ─── accepted ──────────────────────────────────────────────────────────
8089

8190
test("accepted BUY: resume action surfaces", () => {
@@ -113,18 +122,29 @@ test("accepted PAY: status only", () => {
113122
// no-action terminal-ish state where the user waits for the merchant
114123
// to complete OR for the order to auto-cancel.
115124

116-
test("paid BUY: no action regardless of elapsed (chain requires CANCELLED)", () => {
117-
for (const elapsed of [
118-
5 * 60 * 1000,
119-
BUY_DISPUTE_OPEN_MS,
120-
10 * 60 * 60 * 1000,
121-
BUY_DISPUTE_CLOSE_MS + 1,
122-
]) {
125+
test("paid BUY: status escalates by elapsed, no action (chain requires CANCELLED)", () => {
126+
const cases: ReadonlyArray<{ elapsed: number; expect: RegExp | string }> = [
127+
// < 5min: plain
128+
{ elapsed: 60 * 1000, expect: "Paid · processing payment" },
129+
// 5–30min: taking longer than usual
130+
{ elapsed: 10 * 60 * 1000, expect: "Paid · processing payment · taking longer than usual" },
131+
{ elapsed: 29 * 60 * 1000, expect: "Paid · processing payment · taking longer than usual" },
132+
// ≥ 30min: will resolve within <countdown>, anchored on BUY_DISPUTE_CLOSE_MS
133+
{ elapsed: 30 * 60 * 1000, expect: /^Paid · processing payment · will resolve within / },
134+
{ elapsed: 10 * 60 * 60 * 1000, expect: /^Paid · processing payment · will resolve within / },
135+
// Past BUY_DISPUTE_CLOSE_MS (24h): countdown clamps, falls back to plain
136+
{ elapsed: BUY_DISPUTE_CLOSE_MS + 1, expect: "Paid · processing payment" },
137+
];
138+
for (const { elapsed, expect } of cases) {
123139
const out = computeOrderAction(
124140
baseOrder({ status: "paid", type: "buy", paidAt: 1n }),
125141
PLACED_AT_MS + elapsed,
126142
);
127-
assert.strictEqual(out.statusText, "Paid · processing payment");
143+
if (typeof expect === "string") {
144+
assert.strictEqual(out.statusText, expect, `elapsed=${elapsed}ms`);
145+
} else {
146+
assert.match(out.statusText, expect, `elapsed=${elapsed}ms`);
147+
}
128148
assert.deepStrictEqual(out.action, { kind: "none" });
129149
}
130150
});

‎src/core/order-action.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ export const BUY_DISPUTE_CLOSE_MS = 24 * HOUR;
8282
export const SELL_PAY_DISPUTE_OPEN_MS = 30 * MIN;
8383
export const SELL_PAY_DISPUTE_CLOSE_MS = 7 * DAY;
8484

85+
// Threshold past which a still-unmatched PLACED order is "taking longer than
86+
// usual". Anything beyond this is past the chain's typical accept window, so
87+
// the row's status text reflects the staleness even when the on-chain status
88+
// hasn't flipped to CANCELLED yet (the chain only flips on
89+
// `autoCancelExpiredOrders` keeper sweeps, which can lag).
90+
export const PLACED_STALE_THRESHOLD_MS = 5 * MIN;
91+
92+
// BUY/PAID staleness threshold (5min handled by PLACED_STALE_THRESHOLD_MS):
93+
// < 5min → "Paid · processing payment"
94+
// 5–30min → "Paid · processing payment · taking longer than usual"
95+
// ≥ 30min → "Paid · processing payment · will resolve within <countdown>"
96+
// The "will resolve within" countdown is anchored on `BUY_DISPUTE_CLOSE_MS`
97+
// (24h after placement) — the chain's outermost resolution deadline.
98+
export const BUY_PAID_PROCESSING_WINDOW_MS = 30 * MIN;
99+
85100
export function computeOrderAction(
86101
order: Order,
87102
nowMs: number,
@@ -109,6 +124,16 @@ export function computeOrderAction(
109124

110125
switch (order.status) {
111126
case "placed":
127+
// The chain's status field is sticky on PLACED until an explicit
128+
// `autoCancelExpiredOrders` sweep runs, which can lag the actual
129+
// accept-window expiry. Past `PLACED_STALE_THRESHOLD_MS` we surface
130+
// the staleness in the row so users don't keep waiting on an order
131+
// that no merchant can pick up anymore.
132+
if (elapsed >= PLACED_STALE_THRESHOLD_MS) {
133+
return noAction(
134+
"Placed · still matching, this is taking longer than usual",
135+
);
136+
}
112137
return noAction("Placed · matching");
113138

114139
case "accepted":
@@ -123,6 +148,28 @@ export function computeOrderAction(
123148
// raiseDispute rejects this status (contracts-v4 #raiseDispute
124149
// gates BUY on status=CANCELLED). The user waits for completion
125150
// or auto-cancellation.
151+
//
152+
// Three tiers of urgency:
153+
// < 5min → plain "Paid · processing payment"
154+
// 5–30min → "· taking longer than usual" (gentle warning)
155+
// ≥ 30min → "· will resolve within <countdown>" anchored on
156+
// the contract's auto-cancel deadline. Gives the
157+
// user a hard upper bound when the merchant has
158+
// clearly missed the typical window.
159+
if (elapsed >= BUY_PAID_PROCESSING_WINDOW_MS) {
160+
const remaining = BUY_DISPUTE_CLOSE_MS - elapsed;
161+
if (remaining > 0) {
162+
return noAction(
163+
`Paid · processing payment · will resolve within ${formatRemaining(remaining)}`,
164+
);
165+
}
166+
return noAction("Paid · processing payment");
167+
}
168+
if (elapsed >= PLACED_STALE_THRESHOLD_MS) {
169+
return noAction(
170+
"Paid · processing payment · taking longer than usual",
171+
);
172+
}
126173
return noAction("Paid · processing payment");
127174
}
128175
// SELL or PAY in PAID state means the user has been paid; they

‎src/ui/Modal.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ interface ModalProps {
2929
ariaLabelledBy?: string;
3030
/** Fallback accessible name when no labelling heading exists. */
3131
ariaLabel?: string;
32+
/** Theme CSS vars applied to the backdrop so the card surface + inner
33+
* content render against the integrator's palette. The Modal portals
34+
* to `document.body`, escaping the calling widget's root — without
35+
* this, the card's `var(--p2p-color-bg, #ffffff)` falls back to white
36+
* on dark themes, producing white-on-white text. Pass the same
37+
* `themeToCssVars(theme)` result the calling widget applies to its
38+
* root. */
39+
themeStyle?: React.CSSProperties;
3240
children: React.ReactNode;
3341
}
3442

@@ -37,6 +45,7 @@ export function Modal({
3745
onClose,
3846
ariaLabelledBy,
3947
ariaLabel,
48+
themeStyle,
4049
children,
4150
}: ModalProps) {
4251
const dialogRef = useRef<HTMLDivElement | null>(null);
@@ -100,6 +109,10 @@ export function Modal({
100109
return createPortal(
101110
<div
102111
style={{
112+
// Theme CSS vars first so `color.surface` (`var(--p2p-color-bg, …)`)
113+
// on the card resolves against the integrator's palette instead of
114+
// the white fallback.
115+
...themeStyle,
103116
position: "fixed",
104117
inset: 0,
105118
zIndex: 999999,
@@ -121,6 +134,7 @@ export function Modal({
121134
aria-label={ariaLabelledBy ? undefined : ariaLabel}
122135
style={{
123136
background: color.surface,
137+
color: color.text,
124138
borderRadius: radius.xl,
125139
boxShadow: shadow.pop,
126140
width: "100%",

‎src/widgets/Cashout.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,6 @@ export function Cashout(props: CashoutProps) {
572572
</div>
573573
);
574574

575-
if (mode === "modal") return <Modal open={open} onClose={onClose}>{content}</Modal>;
575+
if (mode === "modal") return <Modal open={open} onClose={onClose} themeStyle={themeStyle}>{content}</Modal>;
576576
return <div style={{ ...S.card, overflow: "hidden" }}>{content}</div>;
577577
}

‎src/widgets/Checkout.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,6 @@ export function Checkout(props: CheckoutProps) {
714714
</div>
715715
);
716716

717-
if (mode === "modal") return <Modal open={open} onClose={onClose}>{content}</Modal>;
717+
if (mode === "modal") return <Modal open={open} onClose={onClose} themeStyle={themeStyle}>{content}</Modal>;
718718
return <div style={{ ...S.card, overflow: "hidden" }}>{content}</div>;
719719
}

‎src/widgets/ContactSupport.tsx‎

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -124,17 +124,15 @@ export function ContactSupport(props: ContactSupportProps) {
124124
effectiveDispute === "resolved";
125125

126126
const handleClick = useCallback(() => {
127-
if (inWindow) {
128-
if (!txSigner) {
129-
// No signer wired → embedder hasn't enabled the report flow.
130-
// Nothing to do; the chip would have already been styled
131-
// disabled at render time.
132-
return;
133-
}
127+
// In-window with a tx signer → open the on-chain report flow.
128+
// In-window without a tx signer → fall through to chat. The embedder
129+
// hasn't wired the dispute write path, but the user still needs a
130+
// recourse, and chat will surface "Support not available yet" if the
131+
// bridge has no inbox bound. Better than a dead-button experience.
132+
if (inWindow && txSigner) {
134133
setModalState({ kind: "report" });
135134
return;
136135
}
137-
// Otherwise click opens the chat thread.
138136
setModalState({ kind: "chat-signing" });
139137
setChatAttempt((a) => a + 1);
140138
}, [inWindow, txSigner]);
@@ -247,7 +245,6 @@ export function ContactSupport(props: ContactSupportProps) {
247245
windowOpenMs,
248246
windowCloseMs,
249247
})}
250-
disabled={!txSigner}
251248
onClick={handleClick}
252249
/>
253250
) : (
@@ -259,6 +256,7 @@ export function ContactSupport(props: ContactSupportProps) {
259256
<Modal
260257
open={modalState.kind !== "closed"}
261258
onClose={closeModal}
259+
themeStyle={themeStyle}
262260
ariaLabelledBy={
263261
modalState.kind === "report"
264262
? "report-problem-title"
@@ -302,22 +300,19 @@ export function ContactSupport(props: ContactSupportProps) {
302300
interface ReportChipProps {
303301
remainingMs: number;
304302
filled: number;
305-
disabled: boolean;
306303
onClick: () => void;
307304
}
308305

309306
function ReportChip({
310307
remainingMs,
311308
filled,
312-
disabled,
313309
onClick,
314310
}: ReportChipProps) {
315311
return (
316312
<button
317313
type="button"
318314
data-contact-support-chip
319315
aria-label="Contact Support"
320-
disabled={disabled}
321316
onClick={onClick}
322317
style={{
323318
display: "inline-flex",
@@ -332,8 +327,7 @@ function ReportChip({
332327
fontSize: font.sm,
333328
fontWeight: weight.regular,
334329
fontFamily: "var(--p2p-font, inherit)",
335-
cursor: disabled ? "not-allowed" : "pointer",
336-
opacity: disabled ? 0.55 : 1,
330+
cursor: "pointer",
337331
}}
338332
>
339333
<Doughnut filled={filled} />

‎src/widgets/Support.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export function Support(props: SupportProps) {
176176
open={open}
177177
onClose={handleClose}
178178
ariaLabelledBy="p2p-support-title"
179+
themeStyle={themeStyle}
179180
>
180181
<DialogContent
181182
orderId={orderId}

0 commit comments

Comments
 (0)