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
121 changes: 101 additions & 20 deletions examples/playground/src/app/(playground)/payments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,75 @@ function BuyVetButton() {
const HOOK_SNIPPET = `import { useTransakCheckout } from '@vechain/vechain-kit';

function BuyVetCustom() {
const { open, status, widgetReady } = useTransakCheckout(
() => console.log('done'),
);
// Transak opens in a new tab (status 'ready' + widgetUrl) -- there's no
// postMessage/order event to detect completion from it, so the user
// confirms manually via markCompleted(). The URL is single-use with a
// 5-minute TTL: markWidgetUrlOpened() (called on click) and
// widgetUrlExpired (flips after 5 minutes) tell you when to mint a new
// one via open() instead of relinking the stale URL.
const {
open,
status,
widgetUrl,
widgetUrlExpired,
markWidgetUrlOpened,
markCompleted,
} = useTransakCheckout(() => console.log('done'));

if (status === 'ready') {
if (widgetUrlExpired) {
return (
<button onClick={() => open({ fiatAmount: '30', fiatCurrency: 'USD' })}>
Get a new link
</button>
);
}

return (
<>
<a
href={widgetUrl}
target="_blank"
rel="noopener noreferrer"
onClick={markWidgetUrlOpened}
>
Continue with Transak
</a>
<button onClick={markCompleted}>
I've completed my purchase
</button>
</>
);
}

return (
<button onClick={() => open({ fiatAmount: '30', fiatCurrency: 'USD' })}>
<button
disabled={status === 'processing'}
onClick={() => open({ fiatAmount: '30', fiatCurrency: 'USD' })}
>
Buy VET
</button>
// status/widgetReady drive your own UI -- see TransakCheckoutModal
// in the vechain-kit source for a full reference implementation.
);
Comment thread
victhorbi marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
`;

const buttonStyle = {
padding: '8px 16px',
borderRadius: 8,
border: '1px solid rgba(128,128,128,0.3)',
cursor: 'pointer',
} as const;

function CustomHookDemo() {
const { t } = useTranslation();
const { open, status } = useTransakCheckout();
const {
open,
status,
widgetUrl,
widgetUrlExpired,
markWidgetUrlOpened,
markCompleted,
} = useTransakCheckout();

return (
<VStack align="stretch" spacing={3}>
Expand All @@ -53,17 +105,46 @@ function CustomHookDemo() {
{t('status')}: {status}
</Text>
</HStack>
<button
onClick={() => open({ fiatAmount: '30', fiatCurrency: 'USD' })}
style={{
padding: '8px 16px',
borderRadius: 8,
border: '1px solid rgba(128,128,128,0.3)',
cursor: 'pointer',
}}
>
{t('Buy $30 VET (custom trigger)')}
</button>
{status === 'ready' ? (
<HStack>
{widgetUrlExpired ? (
<button
onClick={() =>
open({
fiatAmount: '30',
fiatCurrency: 'USD',
})
}
style={buttonStyle}
>
{t('Get a new link')}
</button>
) : (
<a
href={widgetUrl ?? undefined}
target="_blank"
rel="noopener noreferrer"
onClick={markWidgetUrlOpened}
style={buttonStyle}
>
{t('Continue with Transak')}
</a>
)}
<button onClick={markCompleted} style={buttonStyle}>
{t("I've completed my purchase")}
</button>
</HStack>
) : (
<button
disabled={status === 'processing'}
onClick={() =>
open({ fiatAmount: '30', fiatCurrency: 'USD' })
}
style={buttonStyle}
>
{t('Buy $30 VET (custom trigger)')}
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)}
</VStack>
);
}
Expand All @@ -86,7 +167,7 @@ export default function PaymentsPage() {
<DemoSection
title={t('Buy VET')}
description={t(
'Drop-in button: opens the Transak widget, tracks order status, and closes itself on success.',
'Drop-in button: opens Transak in a new tab and lets the user confirm when they are done.',
)}
hooks={['PayWithTransakButton', 'useTransakCheckout']}
status="NEW"
Expand All @@ -108,7 +189,7 @@ export default function PaymentsPage() {
status="NEW"
code={HOOK_SNIPPET}
aiPrompt={t(
'Build a custom "Top up wallet" card in my app using useTransakCheckout from @vechain/vechain-kit directly (not PayWithTransakButton) so I can fully control the trigger UI. Show the live status (idle/processing/success/error) and disable the button while processing.',
'Build a custom "Top up wallet" card in my app using useTransakCheckout from @vechain/vechain-kit directly (not PayWithTransakButton) so I can fully control the trigger UI. Show the live status (idle/processing/ready/success/error) and disable the button while processing.',
)}
aiSkills={['vechain-kit']}
>
Expand Down
1 change: 0 additions & 1 deletion packages/vechain-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
"@solana/web3.js": "^1.98.0",
"@tanstack/react-query": "^5.64.2",
"@tanstack/react-query-devtools": "^5.64.1",
"@transak/ui-js-sdk": "^1.0.0",
"@vechain/contract-getters": "1.3.0",
"@vechain/dapp-kit-react": "2.3.2",
"@vechain/picasso": "^2.1.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,13 @@ import {
FormLabel,
Select,
Icon,
Box,
Spinner,
} from '@chakra-ui/react';
import {
ModalBackButton,
StickyHeaderContainer,
} from '@/components/common';
import { ModalBackButton, StickyHeaderContainer } from '@/components/common';
import { AccountModalContentTypes } from '../../Types';
import { useTranslation } from 'react-i18next';
import { useAccountModalOptions } from '@/hooks/modals/useAccountModalOptions';
import {
TRANSAK_WIDGET_CONTAINER_ID,
useTransakCheckout,
} from '@/hooks/payments/useTransakCheckout';
import { useTransakCheckout } from '@/hooks/payments/useTransakCheckout';
import { useEffect, useState } from 'react';
import { LuDollarSign, LuCircleCheck, LuCircleX } from 'react-icons/lu';

Expand All @@ -39,7 +32,7 @@ export type TransakOnrampContentProps = {
>;
};

type OnrampStep = 'form' | 'processing' | 'success' | 'error';
type OnrampStep = 'form' | 'processing' | 'ready' | 'success' | 'error';

export const TransakOnrampContent = ({
setCurrentContent,
Expand All @@ -51,18 +44,29 @@ export const TransakOnrampContent = ({
const [amount, setAmount] = useState('50');
const [currency, setCurrency] = useState<'usd' | 'eur' | 'gbp'>('usd');

const { open: startCheckout, status, widgetReady } = useTransakCheckout(
const {
open: startCheckout,
close: closeCheckout,
status,
widgetUrl,
widgetUrlExpired,
markWidgetUrlOpened,
markCompleted,
} = useTransakCheckout(
() => setStep('success'),
() => setStep('error'),
);

// When the Transak widget closes without completing, status returns to
// idle -- bring the user back to the form instead of leaving them on the
// processing step with no widget.
// Mirror the hook's status into this component's own step state (which
// additionally tracks the pre-checkout 'form' step the hook has no
// concept of). Skipped once the user has navigated back to 'form' --
// otherwise a widgetUrlBuilder() call still in flight when they clicked
// Back would resolve afterwards and silently pull them into 'ready'.
useEffect(() => {
if (step === 'processing' && status === 'idle') {
setStep('form');
}
if (step === 'form') return;
if (status === 'ready') setStep('ready');
else if (status === 'error') setStep('error');
else if (status === 'success') setStep('success');
}, [status, step]);

const handleBuy = async () => {
Expand All @@ -77,15 +81,20 @@ export const TransakOnrampContent = ({
setCurrentContent('main');
};

const goBackToForm = () => {
// Cancels any in-flight (or already-'ready') checkout so it can't
// resurface after the user has already navigated back.
closeCheckout();
setStep('form');
};

return (
<>
<StickyHeaderContainer>
<ModalHeader>{t('Buy VET')}</ModalHeader>
{!isolatedView && (
<ModalBackButton
onClick={
step === 'form' ? handleBack : () => setStep('form')
}
onClick={step === 'form' ? handleBack : goBackToForm}
/>
)}
<ModalCloseButton />
Expand Down Expand Up @@ -143,44 +152,68 @@ export const TransakOnrampContent = ({

{step === 'processing' && (
<ModalBody>
{/* Transak's own single-embed guidance (docs.transak.com/integration/web/iframe)
sizes the container at `height: 80dvh` with no lower cap — the deeper
steps of the flow (KYC, add-card-details) need close to that much room.
The previous 620px ceiling clipped those steps, so the "add card details"
panel rendered cut off and overlapping the quote screen underneath it.
The spinner is a sibling overlay, never a child of the SDK's own
container div -- the SDK appends/removes the iframe with direct DOM
calls (containerId mode), which would fight React for that node. */}
<Box w="full" h="80dvh" minH="560px" maxH="900px" position="relative">
<Box
id={TRANSAK_WIDGET_CONTAINER_ID}
w="full"
h="full"
borderRadius="xl"
overflow="hidden"
position="relative"
/>
{!widgetReady && (
<Box
position="absolute"
inset={0}
display="flex"
alignItems="center"
justifyContent="center"
borderRadius="xl"
pointerEvents="none"
<VStack py={10}>
<Spinner size="lg" color="blue.400" />
</VStack>
</ModalBody>
)}

{step === 'ready' && (
// Opens in a new tab instead of an embedded iframe -- Transak's
// widget rejects the embedded (containerId) request in some
// production setups (403 "Access Denied", error code
// T-INF-102) while the exact same Secure Widget URL loads fine
// as a top-level navigation. See the PR description for the
// full diagnosis. There is no postMessage/order event to
// detect completion from a separate tab, so the user confirms
// manually below.
<ModalBody>
<VStack spacing={6} align="stretch" w="full" py={4}>
<Text fontSize="sm" textAlign="center">
{widgetUrlExpired
? t(
'This link has expired or was already opened. Get a new one to continue.',
)
: t(
'Continue in the new tab to complete your purchase with Transak, then come back here to confirm.',
)}
</Text>
{widgetUrlExpired ? (
<Button
onClick={handleBuy}
variant="vechainKitPrimary"
w="full"
size="lg"
>
{t('Get a new link')}
</Button>
) : (
<Button
as="a"
href={widgetUrl ?? undefined}
target="_blank"
rel="noopener noreferrer"
onClick={markWidgetUrlOpened}
variant="vechainKitPrimary"
w="full"
size="lg"
isDisabled={!widgetUrl}
>
<Spinner size="lg" color="blue.400" />
</Box>
{t('Continue with Transak')}
</Button>
)}
</Box>
</VStack>
</ModalBody>
)}

{step === 'success' && (
<ModalBody>
<VStack spacing={4} align="center" py={8}>
<Icon as={LuCircleCheck} boxSize={12} color="green.400" />
<Icon
as={LuCircleCheck}
boxSize={12}
color="green.400"
/>
<Text fontWeight="bold" fontSize="lg">
{t('VET purchased successfully')}
</Text>
Expand All @@ -201,9 +234,7 @@ export const TransakOnrampContent = ({
{t('Purchase failed')}
</Text>
<Text fontSize="sm" textAlign="center">
{t(
'Something went wrong. Please try again.',
)}
{t('Something went wrong. Please try again.')}
</Text>
</VStack>
</ModalBody>
Expand All @@ -224,6 +255,18 @@ export const TransakOnrampContent = ({
</ModalFooter>
)}

{step === 'ready' && (
<ModalFooter>
<Button
variant="vechainKitSecondary"
onClick={markCompleted}
w="full"
>
{t("I've completed my purchase")}
</Button>
</ModalFooter>
)}

{step === 'error' && (
<ModalFooter>
<Button
Expand Down
Loading
Loading