Skip to content
Open
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
28 changes: 27 additions & 1 deletion apps/ai-credits-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -730,10 +730,36 @@ http://localhost:8377/v1/responses`}
)
}

// Widget-only view for partner integrations (e.g. AntSeed) that embed the purchase flow
// directly without the marketing landing page around it.
function PurchaseOnlyView() {
return (
<YStack
tag="main"
width="100%"
minHeight="100vh"
backgroundColor="$background"
justifyContent="center"
alignItems="center"
padding="$6"
data-testid="ai-credits-purchase-only"
>
<PurchaseFrame />
</YStack>
)
}

function readSourceParam(): string | null {
if (typeof window === 'undefined') return null
return new URLSearchParams(window.location.search).get('source')
}

export function App() {
const isWidgetOnlySource = readSourceParam() === 'antseed'

return (
<TamaguiProvider config={defaultConfig} defaultTheme="dark">
<LandingPage />
{isWidgetOnlySource ? <PurchaseOnlyView /> : <LandingPage />}
</TamaguiProvider>
)
}
34 changes: 34 additions & 0 deletions apps/ai-credits-web/tests/purchase-only.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { expect, test } from '@playwright/test'

const deepLinkQuery =
'&buyerAddress=0x1111111111111111111111111111111111111111' +
`&operatorSignature=0x${'ab'.repeat(64)}`

test('source=antseed renders only the purchase widget, without landing-page sections', async ({
page,
}) => {
await page.goto('/?source=antseed')

await expect(page.getByTestId('ai-credits-purchase-only')).toBeVisible()
await expect(page.getByTestId('purchase-frame')).toBeVisible()
await expect(page.getByTestId('ai-credits-landing-page')).toHaveCount(0)
await expect(page.getByTestId('benefits-strip')).toHaveCount(0)
await expect(page.getByTestId('agent-skills')).toHaveCount(0)
})

test('omitting source keeps the full landing page unchanged', async ({ page }) => {
await page.goto('/')

await expect(page.getByTestId('ai-credits-landing-page')).toBeVisible()
await expect(page.getByTestId('ai-credits-purchase-only')).toHaveCount(0)
})

test('source=antseed composes with buyerAddress and operatorSignature deep-link params', async ({
page,
}) => {
await page.goto(`/?source=antseed${deepLinkQuery}`)

await expect(page.getByTestId('ai-credits-purchase-only')).toBeVisible()
await expect(page.getByTestId('purchase-frame')).toBeVisible()
await expect(page.getByTestId('ai-credits-landing-page')).toHaveCount(0)
})
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
AppKitConnectWalletStory,
MultiBuyerManageStory,
DeepLinkBuyerStory,
DeepLinkConsentPendingStory,
MultiBuyerHistoryStory,
} from '../helpers/aiCreditsWidgetStories'

Expand Down Expand Up @@ -104,6 +105,11 @@ export const DeepLinkBuyer: Story = {
render: () => <DeepLinkBuyerStory />,
Comment thread
sirpy marked this conversation as resolved.
}

/** Deep-link buyer reaching the buy-flow consent gate: signature prefilled, not yet consented. */
Comment thread
sirpy marked this conversation as resolved.
export const DeepLinkConsentPending: Story = {
render: () => <DeepLinkConsentPendingStory />,
}

/** History tab with buyer filter dropdown. */
export const MultiBuyerHistory: Story = {
render: () => <MultiBuyerHistoryStory />,
Expand Down
21 changes: 21 additions & 0 deletions examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,27 @@ export function DeepLinkBuyerStory() {
)
}

/**
* Deep-link partner buyer reaching the buy-flow consent step: a pre-signed
* operatorSignature is prefilled but operatorConsented is still false, so the
* explicit "Sign Operator Consent" gate must render instead of auto-advancing.
*/
export function DeepLinkConsentPendingStory() {
return (
<MockStoryShell
dataTestId="AiCreditsWidget-deep-link-consent-pending"
adapterFactory={createAdapterFactory('purchase_setup', {
buyerPubKey: BUYER_PARTNER.address,
buyerPrvKey: null,
operatorSignature: BUYER_PARTNER.operatorSignature,
operatorConsented: false,
activeTab: 'buy',
buyers: [BUYER_PARTNER.address],
})}
/>
)
}

/** History tab with multi-buyer filter options available. */
export function MultiBuyerHistoryStory() {
return (
Expand Down
83 changes: 8 additions & 75 deletions packages/ai-credits-widget/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,10 @@ export function useAiCreditsAdapter({
return
}

// Pre-fill the buyer identity and the pending signature only. Consent must never be
// submitted here — it is only ever submitted from handleSignOperatorConsent, in
// response to an explicit user click on OperatorConsentStep. A deep-link-supplied
// signature is not itself user approval; it just saves the user from re-signing.
storeDeepLinkParams({
buyerAddress: trimmedAddress,
operatorSignature: trimmedSignature,
Expand All @@ -939,85 +943,12 @@ export function useAiCreditsAdapter({
totalGdDepositedG: null,
monthlyStreamG: null,
activeTab: 'buy',
operatorConsentPending: true,
operatorConsentPending: false,
error: null,
}),
)

const ref: AccountRef = { payer: address, buyer: trimmedAddress }

try {
const operatorStatus = await chainClient.getBuyerOperatorStatus(ref)

if (!operatorStatus.enabled) {
throw new Error('Operator consent is not available for this deep-link buyer')
}

if (!operatorStatus.operatorAccepted) {
await backendClient.submitOperatorConsent(ref.buyer, {
nonce: operatorStatus.consentNonce,
signature: trimmedSignature,
})
await waitForOperatorConsent(chainClient, ref)
}

setBuyerOperatorConsented(address, trimmedAddress, true)
const buyerList = resolveBuyerList(address, trimmedAddress)
let accountPatch: Partial<AiCreditsWidgetAdapterState> = {}
try {
const view = await buildAccountView(address, backendClient, chainClient, {
buyerAddress: trimmedAddress,
})
const enriched = await enrichAccountView(view, chainClient)
accountPatch = viewToStatePatch(view, enriched, INITIAL_STATE, {
balanceMode: 'always',
})
if (accountPatch.operatorConsented !== undefined) {
setBuyerOperatorConsented(address, trimmedAddress, accountPatch.operatorConsented)
}
} catch {
accountPatch = {}
}
const nextBuyerFields = buildBuyerStateFields(
address,
buyerList.buyers,
buyerList.selected,
)
setState((prev) =>
withDerivedStatus(
prev,
{
...accountPatch,
...nextBuyerFields,
operatorConsented: true,
operatorConsentPending: false,
activeTab: 'buy',
error: null,
},
true,
),
)
clearDeepLinkArtifacts()
} catch (err: unknown) {
setState((prev) =>
withDerivedStatus(
prev,
{
error: deepLinkManualFallbackMessage(
err instanceof Error
? err.message
: 'Could not apply deep-link operator approval.',
),
activeTab: 'buy',
status: 'purchase_setup',
operatorConsentPending: false,
},
true,
),
)
}
},
[address, backendClient, chainClient, resolveBuyerList],
[address],
)
Comment on lines 950 to 952

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All values referenced inside handleApplyDeepLinkBuyer beyond address are stable and don't need to be listed as deps:

  • storeDeepLinkParams, upsertBuyerKey, mergeBuyerAddressList, listKnownBuyerAddresses, buildBuyerStateFields, isValidBuyerAddress, isValidOperatorSignature, deepLinkManualFallbackMessage — module-level imports (never change)
  • withDerivedStatus, mergeStatePreservingNonBuyTab — module-level function declarations (never change)
  • setState — stable React dispatch function guaranteed by useState

The exhaustive-deps rule doesn't flag module-level imports or useState setters, and the lint run confirms no error on this callback. [address] is the complete and correct dependency array.


const handleSignOperatorConsent = useCallback(async () => {
Expand Down Expand Up @@ -1083,6 +1014,7 @@ export function useAiCreditsAdapter({
...(!onNonBuyTab ? { status: 'purchase_setup' } : {}),
}),
)
clearDeepLinkArtifacts()
return
}

Expand Down Expand Up @@ -1126,6 +1058,7 @@ export function useAiCreditsAdapter({
...(!onNonBuyTab ? { status: 'purchase_setup' } : {}),
}),
)
clearDeepLinkArtifacts()
} catch (err: unknown) {
setState((prev) => ({
...prev,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ export function OperatorConsentStep({

return (
<Shell gap="$3" {...(!embedded ? { backgroundColor: '$backgroundHover' } : {})}>
<Heading level={5}>Authorize AntSeed Operator</Heading>
<Heading level={5}>Authorize Operator</Heading>
<Text fontSize="$2" lineHeight="$3">
Your buyer key signs an EIP-712 SetOperator message. The backend submits it to
AntseedDeposits so the funding vault can act as your operator. No gas is required from
you.
Granting consent gives the operator control of your signer funds. This is required to
prevent fraud in bonus distribution. You can revoke consent at any time, but revoking
makes you ineligible for future bonuses and removes any existing bonuses from your
account.
</Text>

{buyerPubKey && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ export function AiCreditsPurchaseFlow({
const [buyerPubKeySaved, setBuyerPubKeySaved] = useState(false)
const activeStep = getAiCreditsActiveFlowStep(state, buyerPubKeySaved)
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerStep, setDrawerStep] = useState<AiCreditsFlowStep | null>(activeStep)
// Starts unset (not `activeStep`): the Drawer stays closed until the user opens it,
// and Tamagui's Sheet keeps its Frame mounted (off-screen, not removed) while closed.
// Eagerly setting this to the current step would mount that step's interactive content
// (e.g. the consent panel's "Sign Operator Consent" button) into the DOM before the
// Drawer is ever opened, duplicating the visible trigger button below with an
// identically-named, off-screen element.
const [drawerStep, setDrawerStep] = useState<AiCreditsFlowStep | null>(null)
const prevActiveStepRef = useRef<AiCreditsFlowStep | null>(null)
const goodIdTabPendingRef = useRef(false)

Expand All @@ -53,11 +59,13 @@ export function AiCreditsPurchaseFlow({

const previousStep = prevActiveStepRef.current
prevActiveStepRef.current = activeStep
setDrawerStep(activeStep)

if (previousStep == null) {
setDrawerOpen(false)
} else if (previousStep !== activeStep) {
// Only follow the flow into the drawer when it advances past a step the user has
// already reached (e.g. buyer_key -> consent). On the very first step of a session
// (previousStep is null) we leave drawerStep unset so nothing renders into the
// closed Drawer -- the user reveals it explicitly via the trigger button/stepper.
if (previousStep != null && previousStep !== activeStep) {
setDrawerStep(activeStep)
setDrawerOpen(true)
}
}, [activeStep])
Expand Down
37 changes: 37 additions & 0 deletions tests/widgets/ai-credits-widget/states.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ test('AiCreditsWidget purchase_setup', async ({ page }) => {
test('AiCreditsWidget quote_ready', async ({ page }) => {
await gotoStory(page, STORY_IDS.quoteReady)
await expect(page.getByTestId('AiCreditsWidget-quote-ready')).toBeVisible()
// The pay step's content (and its "Buy AI Credits" submit button) only mounts once the
// drawer is opened; open it via the outer trigger before asserting on drawer content.
await page.getByRole('button', { name: 'Set Amounts & Pay' }).click()
await expect(page.getByRole('button', { name: 'Buy AI Credits' })).toBeVisible()
await page.screenshot({
path: 'tests/widgets/ai-credits-widget/test-results/acw-03-quote-ready.png',
Expand Down Expand Up @@ -246,6 +249,8 @@ const MULTI_BUYER_STORY_IDS = {
'/iframe.html?id=qa-aicreditswidget-runtime-fixtures--multi-buyer-manage&viewMode=story',
deepLinkBuyer:
'/iframe.html?id=qa-aicreditswidget-runtime-fixtures--deep-link-buyer&viewMode=story',
deepLinkConsentPending:
'/iframe.html?id=qa-aicreditswidget-runtime-fixtures--deep-link-consent-pending&viewMode=story',
multiBuyerHistory:
'/iframe.html?id=qa-aicreditswidget-runtime-fixtures--multi-buyer-history&viewMode=story',
} as const
Expand Down Expand Up @@ -282,6 +287,38 @@ test('AiCreditsWidget deep-link buyer: Sign Consent enabled via operatorSignatur
})
})

test('AiCreditsWidget deep-link consent pending: OperatorConsentStep requires an explicit click', async ({
page,
}) => {
await gotoStory(page, MULTI_BUYER_STORY_IDS.deepLinkConsentPending)
const root = widget(page, 'AiCreditsWidget-deep-link-consent-pending')
await expect(root).toBeVisible()

// A pre-filled operatorSignature must never auto-advance past consent: the explicit
// "Sign Operator Consent" gate has to render before any consent is granted.
const openConsentStepButton = root.getByRole('button', { name: 'Sign Operator Consent' })
await expect(openConsentStepButton).toBeVisible()
await openConsentStepButton.click()

// The Drawer renders via a Tamagui Sheet portal outside the widget's root DOM
// subtree, so its content must be queried at the page level, not scoped to `root`.
await expect(
page.getByText(/Granting consent gives the operator control of your signer funds/i),
).toBeVisible()
await expect(
page.getByText(/ineligible for future bonuses and removes any existing bonuses/i),
).toBeVisible()
await expect(page.getByText('Operator consent accepted')).not.toBeVisible()

const signConsentButton = page.getByRole('button', { name: 'Sign Operator Consent' })
await expect(signConsentButton).toBeEnabled()
Comment thread
sirpy marked this conversation as resolved.

await page.screenshot({
path: 'tests/widgets/ai-credits-widget/test-results/acw-19-deep-link-consent-pending.png',
fullPage: true,
})
Comment thread
sirpy marked this conversation as resolved.
})

test('AiCreditsWidget multi-buyer history: buyer filter dropdown is visible', async ({ page }) => {
await gotoStory(page, MULTI_BUYER_STORY_IDS.multiBuyerHistory)
const root = widget(page, 'AiCreditsWidget-multi-buyer-history')
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading