Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9716e8f
feat(payments): run every Payments flow in demo mode, with a simulate…
arseniy-nikitochkin Sep 29, 2026
26498df
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Sep 29, 2026
854acfe
feat(payments): put the Demo switch behind its own payments-demo-mode…
arseniy-nikitochkin Sep 29, 2026
af5033e
fix(payments): close demo-mode review gaps
arseniy-nikitochkin Oct 1, 2026
ab8dc38
test(payments): cover the demo handlers' refusals and session budgets
arseniy-nikitochkin Oct 1, 2026
1599dcd
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
de7f725
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
4893317
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
c009d32
refactor(payments): lift the counterparty requirements hook's pure de…
arseniy-nikitochkin Oct 1, 2026
d8e84a2
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
db8ed67
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
e64f176
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
6107efd
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
32072be
fix(payments): stop the demo switch overstating what stays private
arseniy-nikitochkin Oct 1, 2026
8ca98df
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
7a3caa7
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
7d4e207
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
1129c81
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
ca16099
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
fccd56a
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
372f39c
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
1847d32
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 1, 2026
0f592bd
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 2, 2026
6b064ad
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 2, 2026
c1673db
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 2, 2026
c6fb851
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 2, 2026
01f4655
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 2, 2026
eb5f91d
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 2, 2026
7c1bd15
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 2, 2026
341e251
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 2, 2026
76a7230
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 2, 2026
3f90e84
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 2, 2026
231093a
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 2, 2026
d2f16c8
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 2, 2026
5225334
Merge branch 'feat/payments-redesign-4-activity' into feat/payments-r…
arseniy-nikitochkin Oct 2, 2026
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
3 changes: 3 additions & 0 deletions apps/sdp-web/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ SENTRY_AUTH_TOKEN=
# SDP_FLAG_NEW_DESIGN_CONTACTS=false
# SDP_FLAG_NEW_DESIGN_PAY_DEPOSIT=false
# SDP_FLAG_NEW_DESIGN_ACTIVITY=false
# Payments' Demo switch (sample data, simulated ramps and KYC). Needs NEW DESIGN; on by default
# outside production.
# SDP_FLAG_PAYMENTS_DEMO_MODE=false
# Dashboard module flags are off by default. They hide dashboard UI only; the
# public API and API key authoring remain available.
# CUSTODY_ENABLED=true
Expand Down
26 changes: 26 additions & 0 deletions apps/sdp-web/messages/en/dashboard-payments.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
{
"DashboardPayments": {
"demo": {
"label": "Demo",
"turnOn": "Try Payments with sample data. Payments, contacts and provider steps are simulated: nothing is saved to your project or sent to a payment provider. Address searches still use the real address lookup. Your demo changes reset when you reload the page.",
"turnOff": "Turn off demo mode",
"sandboxOnly": "Demo mode runs on sandbox projects. Switch to your sandbox project to try it.",
"noPayLink": "Demo requests have no pay link to share. Turn off demo mode to create a real one.",
"notice": {
"state": "Demo mode",
"body": "Sample data for presentation. Every action is simulated."
},
"checkout": {
"title": "{provider} checkout",
"onrampBody": "In demo mode, {provider}'s checkout doesn't open. Use Simulate deposit below to see the deposit arrive.",
"offrampBody": "In demo mode, {provider}'s page doesn't open. Send the funds with the button below to finish the payout.",
"settling": "Payment received. {provider} is delivering the funds to the wallet."
},
"verification": {
"simulate": "Simulate verification",
"simulating": "Simulating verification",
"body": "In demo mode, {provider}'s verification page doesn't open. Use Simulate verification below to approve the contact.",
"failed": "Couldn't simulate the verification. Try again."
}
},
"signingUnavailable": "Signing is disabled for this wallet.",
"restricted": "Restricted",
"page": {
Expand Down Expand Up @@ -178,6 +201,9 @@
"transferStatus": "Status: {status}",
"submittingOnchainTransfer": "Submitting on-chain transfer.",
"simulatingQuoteFunding": "Simulating quote funding.",
"simulateDeposit": "Simulate deposit",
"simulatingDeposit": "Simulating deposit",
"depositSimulated": "Deposit simulated",
"quoteFundingSimulated": "Quote funding simulated.",
"quoteSimulationFailed": "Quote simulation failed.",
"sandboxSimulationFailed": "Sandbox simulation failed.",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { proxyToSdpApi } from "@/lib/sdp-api";

/**
* Demo mode's Simulate verification. The SDP API has no such endpoint: demo mode answers this
* path, and refuses it outside the demo before anything is sent upstream.
*/
export async function POST(request: Request) {
return proxyToSdpApi({
request,
traceSource: "route.dashboard.payments.demo.verifications.post",
path: "/v1/payments/demo/verifications",
});
}
11 changes: 10 additions & 1 deletion apps/sdp-web/src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getAuthEntryPath } from "@/lib/auth-entry";
import { resolveDashboardAccess } from "@/lib/dashboard-access";
import { type DashboardCacheScope, getDashboardCacheScopeKey } from "@/lib/dashboard-cache-scope";
import { resolveDashboardProjectSelection } from "@/lib/dashboard-project-selection";
import { PAYMENTS_DEMO_COOKIE_NAME } from "@/lib/payments-demo/demo-cookie";
import { PROJECT_COOKIE_NAME } from "@/lib/project-cookie";
import { loadQuickStartStep } from "@/lib/quick-start-server";
import { getSdpAuth, listSdpProjects } from "@/lib/sdp-api";
Expand Down Expand Up @@ -68,7 +69,15 @@ export default async function DashboardLayout({ children }: { children: ReactNod
shouldRepairInitialProjectCookie={projectSelection.shouldRepairCookie}
>
<NetworkDebugProvider>
<DashboardShell flags={flags}>{children}</DashboardShell>
<DashboardShell
flags={flags}
paymentsDemo={{
demoProjectId: cookieStore.get(PAYMENTS_DEMO_COOKIE_NAME)?.value ?? null,
cookieProjectId,
}}
>
{children}
</DashboardShell>
</NetworkDebugProvider>
</DashboardWorkspaceProvider>
);
Expand Down
27 changes: 27 additions & 0 deletions apps/sdp-web/src/app/dashboard/payments/payments-workspace.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,33 @@ export async function simulateSandboxTransfer(
}
}

/**
* Demo mode only: the provider approves the contact's identity check (Simulate verification).
* Its own path, which only demo mode answers: outside the demo it is refused before reaching
* the SDP API, whose sandbox has no such step.
*/
export async function simulateDemoVerification(
input: { provider: "bvnk"; counterpartyId: string },
t: Translate
): Promise<void> {
const response = await fetch("/api/dashboard/payments/demo/verifications", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});

if (!response.ok) {
throw new Error(
getApiError(
await response.json().catch(() => null),
t("DashboardPayments.demo.verification.failed")
)
);
}
}

export async function runComplianceCheck(
address: string,
intent: ComplianceIntent
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";

import type { PaymentTransferSummary, RampDirection, RampProviderId } from "@sdp/types";
import Image from "next/image";
import { useTranslations } from "@/i18n/provider";
import { getRampProviderLabel, RAMP_PROVIDER_LOGOS } from "@/lib/ramps";

/**
* Demo mode's stand-in for a provider's own checkout (MoonPay's and Coinbase's pages, Stripe's
* and MoneyGram's widgets), which can't open on sample data. A deposit is paid with the footer's
* Simulate deposit; a payout's crypto is sent with its Send button, as for a bank payout. Either
* way the transfer then settles and the flow finishes on its own completion screen.
*/
export function DemoProviderCheckout({
direction,
provider,
transfer,
}: {
direction: RampDirection;
provider: RampProviderId;
transfer: PaymentTransferSummary | undefined;
}) {
const t = useTranslations();
const name = getRampProviderLabel(provider);
const settling = transfer !== undefined && transfer.status !== "awaiting_payment";
const body =
direction === "offramp"
? t("DashboardPayments.demo.checkout.offrampBody", { provider: name })
: settling
? t("DashboardPayments.demo.checkout.settling", { provider: name })
: t("DashboardPayments.demo.checkout.onrampBody", { provider: name });

return (
<section className="space-y-4 rounded-card border border-border-subtle p-6">
<div className="flex items-center gap-3">
<Image
src={RAMP_PROVIDER_LOGOS[provider]}
alt=""
width={32}
height={32}
className="size-8 shrink-0 rounded-control object-contain"
/>
<h2 className="text-body font-medium text-primary">
{t("DashboardPayments.demo.checkout.title", { provider: name })}
</h2>
</div>
<p className="text-body text-secondary">{body}</p>
</section>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import { useMemo } from "react";
import { Combobox } from "@/components/ui/combobox";
import type { MessageKey, TranslationValues } from "@/i18n/messages";
import { useTranslations } from "@/i18n/provider";
import { usePaymentsDemo } from "@/lib/payments-demo/payments-demo-context";
import { hasEnabledRampProvider } from "@/lib/provider-availability";
import type { OfframpWizard } from "../hooks/use-offramp-wizard.redesign";
import { walletComboboxOptions } from "../wallet-options";
import { BvnkAgreementConsent } from "./bvnk-agreement-consent.redesign";
import { DemoProviderCheckout } from "./demo-provider-checkout";
import { ManualInstructionsQuote } from "./manual-instructions-quote.redesign";
import { MemoStepContent } from "./memo-step-content.redesign";
import { MoneygramRampWidget } from "./moneygram-ramp-widget";
Expand Down Expand Up @@ -277,6 +279,7 @@ function OfframpMoneygramStep({

function OfframpCompleteStep({ wizard }: { wizard: OfframpWizard }) {
const t = useTranslations();
const demo = usePaymentsDemo();
const {
quote,
transferStatus,
Expand Down Expand Up @@ -317,6 +320,22 @@ function OfframpCompleteStep({ wizard }: { wizard: OfframpWizard }) {
return <RampCompleteScreen direction="offramp" quote={quote} transfer={transferStatus} />;
}

// A provider's own page or widget can't open on sample data; the demo stands in for it.
if (demo && quote.deliveryMode !== "manual_instructions") {
return (
<div className="space-y-6">
<DemoProviderCheckout
direction="offramp"
provider={quote.provider}
transfer={transferStatus}
/>
<div className="border-t border-border-default pt-5">
<RampStatusPanel direction="offramp" transfer={transferStatus} />
</div>
</div>
);
}

if (quote.deliveryMode === "hosted") {
return (
<MoonpayRampFrame
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
"use client";

import { isMuralSandboxPayinCurrency, isTerminalRampTransferStatus } from "@sdp/types";
import { isTerminalRampTransferStatus } from "@sdp/types";
import { getCryptoRailAssetLabel } from "@sdp/types/payment-rails";
import { DollarSignIcon } from "lucide-react";
import { useThemeScope } from "@/components/theme-scope";
import { Callout } from "@/components/ui/callout";
import { useTranslations } from "@/i18n/provider";
import { usePaymentsDemo } from "@/lib/payments-demo/payments-demo-context";
import { hasEnabledRampProvider } from "@/lib/provider-availability";
import type { OnrampWizard } from "../hooks/use-onramp-wizard.redesign";
import { BvnkAgreementConsent } from "./bvnk-agreement-consent.redesign";
import { CoinbaseQuoteSummary } from "./coinbase/quote-summary.redesign";
import { CoinbaseRampFrame } from "./coinbase/ramp-frame";
import { ContactCombobox, type ContactControls } from "./contact-combobox";
import { DemoProviderCheckout } from "./demo-provider-checkout";
import { DepositTimeline } from "./deposit-timeline";
import { ManualInstructionsQuote } from "./manual-instructions-quote.redesign";
import { MemoStepContent } from "./memo-step-content.redesign";
import { MoneygramRampWidget } from "./moneygram-ramp-widget";
import { MoonpayRampFrame } from "./moonpay-ramp-frame";
import { OnrampReview } from "./onramp-review";
import { hasOnboardingLifecycle, isOnboardingPanelStatus, simulateActionLabels } from "./providers";
import { hasOnboardingLifecycle, isOnboardingPanelStatus } from "./providers";
import { RampCompleteScreen } from "./ramp-complete-screen";
import { RampOnboardingPanel } from "./ramp-onboarding-panel";
import { RampPairProviderSelector } from "./ramp-pair-provider-selector.redesign";
Expand Down Expand Up @@ -159,14 +160,7 @@ function ManualInstructionsStep({
}) {
const t = useTranslations();
const refresh = useThemeScope() === "refresh";
const {
fields,
selectedRampPair,
transferStatus,
quoteSimulationLoading,
quoteSimulationSucceeded,
simulateCurrentQuote,
} = wizard;
const { fields, selectedRampPair, transferStatus } = wizard;
// Terminal check precedes the missing-instructions guard: a dead transfer is not a quote defect.
if (transferStatus !== undefined && isTerminalRampTransferStatus(transferStatus.status)) {
return <RampStatusPanel direction="onramp" transfer={transferStatus} />;
Expand All @@ -180,29 +174,14 @@ function ManualInstructionsStep({
);
}

const labels =
quote.provider === "mural" && !isMuralSandboxPayinCurrency(selectedRampPair.fiatCurrency)
? null
: simulateActionLabels(quote.provider, t);
const simulateAction = labels
? {
loading: quoteSimulationLoading,
succeeded: quoteSimulationSucceeded,
onClick: () => void simulateCurrentQuote(),
icon: <DollarSignIcon />,
idleLabel: labels.idle,
busyLabel: labels.busy,
doneLabel: labels.done,
}
: undefined;
// The sandbox's Simulate deposit is the footer's primary action, not part of the instructions.
const instructionsQuote = (
<ManualInstructionsQuote
amount={fields.amount.trim()}
quote={quote}
fiatCurrency={selectedRampPair.fiatCurrency}
cryptoToken={getCryptoRailAssetLabel(selectedRampPair.assetRail)}
instructions={quote.paymentInstructions}
action={simulateAction}
/>
);
return refresh ? (
Expand Down Expand Up @@ -266,6 +245,7 @@ function HostedQuoteStep({ quote }: { quote: HostedQuoteRecord }) {
* quote's own surface (a hosted frame, a widget, bank instructions).
*/
function QuoteStep({ wizard, quote }: { wizard: OnrampWizard; quote: OnrampQuote }) {
const demo = usePaymentsDemo();
const { transferStatus } = wizard;

if (wizard.showCompleteScreen) {
Expand All @@ -275,6 +255,20 @@ function QuoteStep({ wizard, quote }: { wizard: OnrampWizard; quote: OnrampQuote
return <RampCompleteScreen direction="onramp" quote={quote} transfer={transferStatus} />;
}

// A provider's own checkout can't open on sample data; the demo stands in for it.
if (demo && quote.deliveryMode !== "manual_instructions") {
return (
<div className="space-y-6">
{quote.provider === "coinbase" ? <CoinbaseQuoteSummary quote={quote} /> : null}
<DemoProviderCheckout
direction="onramp"
provider={quote.provider}
transfer={transferStatus}
/>
</div>
);
}

if (quote.provider === "stripe") {
return (
<StripeOnrampFrame clientSecret={quote.clientSecret} publishableKey={quote.publishableKey} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import type { CounterpartyRequirements, RampDirection } from "@sdp/types/ramp-requirements";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/i18n/provider";
import { usePaymentsDemo } from "@/lib/payments-demo/payments-demo-context";
import { getRampProviderLabel } from "@/lib/ramps";
import { openExternalRampUrl } from "@/lib/trusted-ramp-destinations";
import { isOnboardingPanelStatus, onboardingCopy, provisioningDetail } from "./providers";

Expand All @@ -16,14 +18,20 @@ export function RampOnboardingPanel({
onRetry: () => void;
}) {
const t = useTranslations();
const demo = usePaymentsDemo();
if (!isOnboardingPanelStatus(onboarding)) {
throw new Error(`RampOnboardingPanel received non-onboarding status: ${onboarding.status}`);
}
const { provider, status } = onboarding;
const copy = onboardingCopy(onboarding, t);
const Icon = copy.icon;
const hostedAction =
status === "terms_of_service_required"
// The provider's own verification page can't open on sample data; the footer's Simulate
// verification stands in for it.
const demoVerification =
demo && provider === "bvnk" && status === "customer_verification_required";
const hostedAction = demoVerification
? null
: status === "terms_of_service_required"
? { label: t("DashboardPayments.ramps.acceptTerms"), url: onboarding.termsOfServiceUrl }
: status === "customer_verification_required"
? {
Expand All @@ -36,6 +44,13 @@ export function RampOnboardingPanel({
<Icon className={`size-10 ${copy.iconClassName}`} />
<p className="text-lg font-medium text-primary">{copy.title}</p>
<p className="max-w-md text-sm leading-relaxed text-tertiary">{copy.description}</p>
{demoVerification ? (
<p className="max-w-md text-sm leading-relaxed text-secondary">
{t("DashboardPayments.demo.verification.body", {
provider: getRampProviderLabel(provider),
})}
</p>
) : null}
{hostedAction ? (
<Button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { useThemeScope } from "@/components/theme-scope";
import { Modal } from "@/components/ui/modal";
import { useDashboardWorkspace } from "@/contexts/dashboard-workspace-context";
import { useLocale, useTranslations } from "@/i18n/provider";
import { demoRampPairs } from "@/lib/payments-demo/demo-ramp-assets";
import { usePaymentsDemo } from "@/lib/payments-demo/payments-demo-context";
import type { RampProviderAccess } from "@/lib/provider-availability";
import {
findRampPair,
Expand Down Expand Up @@ -437,9 +439,13 @@ export function RampPairProviderSelector({
onProviderSelect,
}: RampPairProviderSelectorProps) {
const { sdpEnvironment } = useDashboardWorkspace();
const demo = usePaymentsDemo();
const refresh = useThemeScope() === "refresh";
const [unavailableDialogOpen, setUnavailableDialogOpen] = useState(false);
const pairs = pairsForDirection(direction, sdpEnvironment, enabledRampProviders);
const pairs = demoRampPairs(
pairsForDirection(direction, sdpEnvironment, enabledRampProviders),
demo
);
const selectedPairSupport = useMemo(
() => findRampPair(pairs, selectedPair),
[pairs, selectedPair]
Expand Down
Loading
Loading