Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions src/widgets/PaymentHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export interface PaymentHistoryProps {
* subpath's `PaymentHistoryWithSupport` is the canonical consumer.
*/
renderRowAction?: (order: Order) => React.ReactNode;
/**
* Optional render slot at the top-right corner of each row. Useful for
* status overlays — e.g. the support subpath uses it to render an
* "Active support" pip alongside the on-chain dispute badge. Rendered
* absolutely positioned so it never shifts the row's primary layout.
*/
renderRowBadge?: (order: Order) => React.ReactNode;
/** Optional title override. */
title?: string;
/**
Expand Down Expand Up @@ -87,7 +94,7 @@ export function PaymentHistory(props: PaymentHistoryProps) {
const {
signer, subgraphUrl, usdcAddress,
chainId = 84532, diamondAddress = DEFAULT_DIAMOND_ADDRESS, rpcUrl,
limit = 20, onResume, renderRowAction,
limit = 20, onResume, renderRowAction, renderRowBadge,
filter = "all",
refreshKey,
optimisticUpdates,
Expand Down Expand Up @@ -262,7 +269,7 @@ export function PaymentHistory(props: PaymentHistoryProps) {
{past.length > 0 && <p style={{ ...S.label, marginBottom: 8 }}>Pending</p>}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{pending.map((o) => (
<OrderRow key={o.orderId.toString()} order={o} onResume={onResume} renderRowAction={renderRowAction} highlight config={currencyConfigs[o.currency]} />
<OrderRow key={o.orderId.toString()} order={o} onResume={onResume} renderRowAction={renderRowAction} renderRowBadge={renderRowBadge} highlight config={currencyConfigs[o.currency]} />
))}
</div>
</div>
Expand All @@ -273,7 +280,7 @@ export function PaymentHistory(props: PaymentHistoryProps) {
{pending.length > 0 && <p style={{ ...S.label, marginBottom: 8 }}>Past</p>}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{past.map((o) => (
<OrderRow key={o.orderId.toString()} order={o} onResume={onResume} renderRowAction={renderRowAction} config={currencyConfigs[o.currency]} />
<OrderRow key={o.orderId.toString()} order={o} onResume={onResume} renderRowAction={renderRowAction} renderRowBadge={renderRowBadge} config={currencyConfigs[o.currency]} />
))}
</div>
</div>
Expand All @@ -282,10 +289,11 @@ export function PaymentHistory(props: PaymentHistoryProps) {
);
}

function OrderRow({ order, onResume, renderRowAction, highlight, config }: {
function OrderRow({ order, onResume, renderRowAction, renderRowBadge, highlight, config }: {
order: Order;
onResume?: (orderId: string) => void;
renderRowAction?: (order: Order) => React.ReactNode;
renderRowBadge?: (order: Order) => React.ReactNode;
highlight?: boolean;
config?: { buyPrice: bigint; smallOrderThreshold: bigint; smallOrderFixedFee: bigint };
}) {
Expand Down Expand Up @@ -322,8 +330,23 @@ function OrderRow({ order, onResume, renderRowAction, highlight, config }: {
alignItems: "center",
justifyContent: "space-between",
gap: 12,
position: "relative",
}}
>
{renderRowBadge ? (
<div
style={{
position: "absolute",
top: 6,
right: 10,
display: "inline-flex",
alignItems: "center",
pointerEvents: "none",
}}
>
<div style={{ pointerEvents: "auto" }}>{renderRowBadge(order)}</div>
</div>
) : null}
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<StatusBadge status={order.status} />
Expand Down
38 changes: 20 additions & 18 deletions src/widgets/PaymentHistoryWithSupport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,24 +68,26 @@ export function PaymentHistoryWithSupport(
return (
<PaymentHistory
{...orderHistoryProps}
renderRowAction={(order: { orderId: { toString(): string }; disputeStatus?: string }) => {
const id = order.orderId.toString();
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
{activeOrderIds.has(id) ? <ActiveSupportPip /> : null}
<Support
orderId={id}
originApp={support.originApp}
signer={support.signer}
bridgeUrl={support.bridgeUrl}
disputeStatus={(order.disputeStatus ?? "none") as SupportStatus}
chatwootBaseUrl={support.chatwootBaseUrl}
chatwootInboxIdentifier={support.chatwootInboxIdentifier}
theme={support.theme}
/>
</span>
);
}}
renderRowBadge={(order: { orderId: { toString(): string } }) =>
activeOrderIds.has(order.orderId.toString()) ? <ActiveSupportPip /> : null
}
renderRowAction={(order: { orderId: { toString(): string }; disputeStatus?: string }) => (
// The bridge's sign-in handler is the source of truth for "can
// this order open a chat" — it resolves the order's on-chain
// circleId → inbox, and returns `chatwoot: null` when nothing
// is bound. The widget silently closes on that path. So the
// Support button is always rendered; gating is server-side.
<Support
orderId={order.orderId.toString()}
originApp={support.originApp}
signer={support.signer}
bridgeUrl={support.bridgeUrl}
disputeStatus={(order.disputeStatus ?? "none") as SupportStatus}
chatwootBaseUrl={support.chatwootBaseUrl}
chatwootInboxIdentifier={support.chatwootInboxIdentifier}
theme={support.theme}
/>
)}
/>
);
}
Expand Down
137 changes: 104 additions & 33 deletions src/widgets/Support.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
type Phase =
| { kind: "idle" }
| { kind: "signing" }
| { kind: "loading-chat" }
| { kind: "ready"; session: SignInResponse }
| { kind: "no-chatwoot"; session: SignInResponse }
| { kind: "error"; reason: string };

export function Support(props: SupportProps) {
Expand Down Expand Up @@ -55,24 +55,44 @@ export function Support(props: SupportProps) {
let cancelled = false;
(async () => {
try {
const cached = readCachedSession(bridgeUrl, signer.address, orderId);
let session = cached;
// Always show the signing loader immediately so the click is
// never a flash of nothing. If we skip straight to a cached
// chatwoot:null and silently close, the user perceives the
// button as broken.
setPhase({ kind: "signing" });
let session = readCachedSession(bridgeUrl, signer.address, orderId);
if (!session) {
setPhase({ kind: "signing" });
session = await signInWithBridge({ signer, bridgeUrl, orderId });
writeCachedSession(bridgeUrl, signer.address, session, orderId);
// Only cache when chatwoot resolved. A null chatwoot session
// means the order isn't bound to a circle yet (pre-acceptance)
// — that's transient state we don't want to cache for 7 days.
if (session.chatwoot) {
writeCachedSession(bridgeUrl, signer.address, session, orderId);
}
}
if (cancelled) return;
if (!session.chatwoot) {
setPhase({ kind: "no-chatwoot", session });
// Order not yet bound to a circle (pre-acceptance on chain) or
// bridge can't resolve an inbox. Nothing to show — silently
// close the modal so the click is a no-op rather than a wall
// of explainer text. The Support button stays clickable; the
// user can retry once the order is accepted.
if (typeof console !== "undefined") {
console.info(
"[support] no chatwoot session for this order; closing modal",
);
}
setOpen(false);
onClose?.();
return;
}
setPhase({ kind: "loading-chat" });
await bootChatwoot(session.chatwoot);
if (cancelled) return;
openChatwoot();
// Chatwoot's own widget is now the user-facing surface. Auto-close
// our modal so it does not sit on top of the chat. The modal stays
// visible only for signing / no-chatwoot / error phases.
// visible only for signing / loading-chat / error phases.
setOpen(false);
onClose?.();
} catch (err) {
Expand Down Expand Up @@ -212,15 +232,19 @@ function PhaseView({ phase }: { phase: Phase }) {

if (phase.kind === "signing") {
return (
<div
style={{
...box,
border: "1px dashed var(--support-color-muted)",
color: "var(--support-color-muted)",
}}
>
Signing in with your wallet. Approve the message request to continue.
</div>
<Loader
title="Signing in"
body="Approve the message request to open support for this order."
/>
);
}

if (phase.kind === "loading-chat") {
return (
<Loader
title="Loading chat"
body="Connecting to the Payment Support Team..."
/>
);
}

Expand All @@ -239,23 +263,6 @@ function PhaseView({ phase }: { phase: Phase }) {
);
}

if (phase.kind === "no-chatwoot") {
return (
<div
style={{
...box,
border: "1px dashed var(--support-color-muted)",
color: "var(--support-color-muted)",
}}
>
Bridge sign-in succeeded. No support inbox is provisioned for this
order's circle yet, so the chat surface is not available. Once the
first dispute fires the inbox is auto-created and Support opens
directly.
</div>
);
}

if (phase.kind === "error") {
return (
<div
Expand All @@ -273,6 +280,70 @@ function PhaseView({ phase }: { phase: Phase }) {
return null;
}

function Loader({ title, body }: { title: string; body: string }) {
return (
<div
style={{
marginTop: 16,
padding: 24,
borderRadius: "var(--support-radius)",
border: "1px solid var(--support-color-muted)",
display: "flex",
alignItems: "center",
gap: 14,
}}
>
<Spinner />
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: 14,
fontWeight: 500,
color: "var(--support-color-text)",
marginBottom: 2,
}}
>
{title}
</div>
<div
style={{
fontSize: 12,
color: "var(--support-color-muted)",
lineHeight: 1.4,
}}
>
{body}
</div>
</div>
</div>
);
}

function Spinner() {
const id = "support-spinner-keyframes";
if (typeof document !== "undefined" && !document.getElementById(id)) {
const style = document.createElement("style");
style.id = id;
style.textContent =
"@keyframes support-spin { to { transform: rotate(360deg); } }";
document.head.appendChild(style);
}
return (
<div
aria-hidden="true"
style={{
width: 18,
height: 18,
borderRadius: "50%",
border: "2px solid var(--support-color-muted)",
borderTopColor: "transparent",
animation: "support-spin 0.8s linear infinite",
flexShrink: 0,
}}
/>
);
}

function shortenId(id: string): string {
if (id.length <= 10) return id;
return `${id.slice(0, 6)}...${id.slice(-4)}`;
Expand Down
Loading
Loading