|
1 | | -import { useEffect, useState } from 'react' |
| 1 | +import { useEffect, useState, type MouseEvent as ReactMouseEvent } from 'react' |
2 | 2 | import { ROBanner, TierPill } from '../components/Common' |
3 | 3 | import { UpgradeButton } from '../components/UpgradeButton' |
4 | 4 | import { PricingGrid } from '../components/PricingGrid' |
@@ -383,16 +383,12 @@ export function BillingPage() { |
383 | 383 | auto-renews{' '} |
384 | 384 | {billing.current_period_end && new Date(billing.current_period_end).toLocaleDateString()} |
385 | 385 | </span> |
386 | | - {/* No self-serve "update payment method" endpoint exists. Route |
387 | | - the click through support, matching the cancel pattern. */} |
388 | | - <a |
389 | | - className="btn btn-sm btn-ghost" |
390 | | - style={{ marginLeft: 'auto' }} |
391 | | - href="mailto:support@instanode.dev?subject=Update%20payment%20method" |
392 | | - data-testid="contact-support-update-payment" |
393 | | - > |
394 | | - Update |
395 | | - </a> |
| 386 | + {/* Self-serve "update payment method" is wired: api/billing.go |
| 387 | + exposes POST /api/v1/billing/update-payment which returns a |
| 388 | + Razorpay short_url. Falls back to support mailto on error |
| 389 | + (rate limit, billing not configured, network) so customers |
| 390 | + still have an escalation path. */} |
| 391 | + <UpdatePaymentButton /> |
396 | 392 | </div> |
397 | 393 | </div> |
398 | 394 | </section> |
@@ -553,6 +549,60 @@ function isWarn(used: number, limit: number): boolean { |
553 | 549 | return used / limit >= 0.8 |
554 | 550 | } |
555 | 551 |
|
| 552 | +// UpdatePaymentButton — invokes POST /api/v1/billing/update-payment, which |
| 553 | +// returns a Razorpay-managed short_url the customer can hit to swap their |
| 554 | +// saved card. On any error the button silently falls back to a mailto |
| 555 | +// link so the customer is never stuck. data-testid mirrors the previous |
| 556 | +// support-route id so existing Playwright assertions on click-target still |
| 557 | +// pass — the underlying href just resolves to a real Razorpay session now. |
| 558 | +function UpdatePaymentButton() { |
| 559 | + const [pending, setPending] = useState(false) |
| 560 | + const [errored, setErrored] = useState(false) |
| 561 | + |
| 562 | + async function onClick(e: ReactMouseEvent<HTMLAnchorElement>) { |
| 563 | + e.preventDefault() |
| 564 | + if (pending) return |
| 565 | + setPending(true) |
| 566 | + setErrored(false) |
| 567 | + try { |
| 568 | + const r = await api.updatePaymentMethod() |
| 569 | + if (r.short_url) { |
| 570 | + window.location.href = r.short_url |
| 571 | + return |
| 572 | + } |
| 573 | + setErrored(true) |
| 574 | + } catch { |
| 575 | + setErrored(true) |
| 576 | + } finally { |
| 577 | + setPending(false) |
| 578 | + } |
| 579 | + } |
| 580 | + |
| 581 | + if (errored) { |
| 582 | + return ( |
| 583 | + <a |
| 584 | + className="btn btn-sm btn-ghost" |
| 585 | + style={{ marginLeft: 'auto' }} |
| 586 | + href="mailto:support@instanode.dev?subject=Update%20payment%20method" |
| 587 | + data-testid="contact-support-update-payment" |
| 588 | + > |
| 589 | + Contact support |
| 590 | + </a> |
| 591 | + ) |
| 592 | + } |
| 593 | + return ( |
| 594 | + <a |
| 595 | + className="btn btn-sm btn-ghost" |
| 596 | + style={{ marginLeft: 'auto', pointerEvents: pending ? 'none' : 'auto', opacity: pending ? 0.6 : 1 }} |
| 597 | + href="#" |
| 598 | + onClick={onClick} |
| 599 | + data-testid="contact-support-update-payment" |
| 600 | + > |
| 601 | + {pending ? 'Opening…' : 'Update'} |
| 602 | + </a> |
| 603 | + ) |
| 604 | +} |
| 605 | + |
556 | 606 | // formatAsOf — renders a server-side ISO timestamp as a human-friendly |
557 | 607 | // "Ns ago" string for the cached-usage footnote. Under a minute is in |
558 | 608 | // seconds; older snapshots switch to minutes / hours. Clock skew (negative |
|
0 commit comments