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
51 changes: 41 additions & 10 deletions web/e2e/artist-gift.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,15 @@ for (const width of [320, 390, 430, 1440]) {
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: info.outputPath(`gift-review-${width}.png`), animations: 'disabled' });
await page.getByRole('button', { name: 'Confirm gift · 0.25 PAS', exact: true }).click();
await expect(dialog).toContainText('Contribution confirmed');
await expect(dialog.getByRole('heading', { name: 'Gift sent', exact: true })).toBeVisible();
await expect(dialog.locator('time')).toBeVisible();
expect((await giftState(page)).sends).toBe(1);
const access = await page.evaluate(() => Reflect.get(window, '__DOTIFY_E2E_CLASSIC_UNLOCK__') as { paid: boolean; accessGranted: boolean });
expect(access.paid).toBe(false);
expect(access.accessGranted).toBe(false);
await page.getByRole('button', { name: 'Close contribution' }).click();
await expect(page.getByRole('button', { name: 'Send a gift', exact: true })).toBeFocused();
await expect(page.getByRole('button', { name: 'Gift sent. View receipt', exact: true })).toBeFocused();
await expect(page.locator('.toast-card[data-tone="success"]')).toContainText('Gift sent');
});
}
for (const width of [320, 390, 430, 1440]) {
Expand Down Expand Up @@ -85,12 +86,20 @@ for (const width of [320, 390, 430, 1440]) {
await expect(contributionDialog(page)).toContainText('Listening access stays unchanged');
await review(page, '0.5', 'tip');
await contributionDialog(page).getByRole('button', { name: 'Confirm tip · 0.5 PAS', exact: true }).click();
await expect(contributionDialog(page)).toContainText('Contribution confirmed');
const dialog = contributionDialog(page);
await expect(dialog.getByRole('heading', { name: 'Tip sent', exact: true })).toBeVisible();
await expect(dialog).toContainText('0.5 PAS for “Deterministic Classic Unlock”. Finalized and recorded.');
const access = await page.evaluate(() => Reflect.get(window, '__DOTIFY_E2E_CLASSIC_UNLOCK__') as { paid: boolean; accessGranted: boolean });
expect(access.paid).toBe(false);
expect(access.accessGranted).toBe(false);
await page.getByRole('button', { name: 'Close contribution' }).click();
await expect(tip).toBeFocused();
const sentTip = page.getByRole('button', { name: 'Tip sent. View receipt', exact: true });
await expect(sentTip).toBeFocused();
await expect(sentTip).toHaveAttribute('data-confirmed', 'true');
const notice = page.locator('.toast-card[data-tone="success"]');
await expect(notice).toContainText('Tip sent');
await expect(notice).toContainText('0.5 PAS for “Deterministic Classic Unlock”');
await page.screenshot({ path: info.outputPath(`tip-confirmed-${width}.png`), animations: 'disabled' });
await page.getByRole('button', { name: 'Unlock listening', exact: true }).click();
await expect(page.getByTestId('classic-unlock-button')).toHaveAccessibleName('Pay 0.5 PAS to unlock Deterministic Classic Unlock');
});
Expand All @@ -99,7 +108,7 @@ test('a confirmed gift is visible in You with a dated exportable receipt', async
await openGift(page);
await review(page, '0.5');
await page.getByRole('button', { name: 'Confirm gift · 0.5 PAS', exact: true }).click();
await expect(contributionDialog(page)).toContainText('Contribution confirmed');
await expect(contributionDialog(page).getByRole('heading', { name: 'Gift sent', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Close contribution' }).click();
await page.getByRole('button', { name: 'You', exact: true }).click();
const history = page.locator('.contribution-history');
Expand Down Expand Up @@ -214,18 +223,18 @@ test('a pending contribution can close and reopen without another transfer', asy
await expect(contributionDialog(page).getByRole('status')).toContainText('Closing does not cancel');
expect((await giftState(page)).sends).toBe(1);
await page.evaluate(() => Reflect.get(window, '__DOTIFY_E2E_DONATION__').complete());
await expect(contributionDialog(page)).toContainText('Contribution confirmed');
await expect(contributionDialog(page).getByRole('heading', { name: 'Gift sent', exact: true })).toBeVisible();
});
test('an interrupted contribution is recovered without a second payment', async ({ page }) => {
await openGift(page, '?e2eGift=delayed');
await review(page, '0.1');
await page.getByRole('button', { name: 'Confirm gift · 0.1 PAS', exact: true }).click();
await expect(contributionDialog(page)).toContainText('Confirmation was interrupted');
await expect(contributionDialog(page)).toContainText('Confirmation is taking longer than expected');
await page.evaluate(() => {
Reflect.get(window, '__DOTIFY_E2E_DONATION__').confirmed = true;
});
await page.getByRole('button', { name: 'Check status · no new payment' }).click();
await expect(contributionDialog(page)).toContainText('Contribution confirmed');
await page.getByRole('button', { name: 'Check payment status' }).click();
await expect(contributionDialog(page).getByRole('heading', { name: 'Gift sent', exact: true })).toBeVisible();
expect((await giftState(page)).sends).toBe(1);
});
test('zero amounts and rejected signatures never show a successful receipt', async ({ page }) => {
Expand All @@ -235,5 +244,27 @@ test('zero amounts and rejected signatures never show a successful receipt', asy
await review(page, '1');
await page.getByRole('button', { name: 'Confirm gift · 1 PAS', exact: true }).click();
await expect(contributionDialog(page)).toContainText('No contribution was sent');
await expect(contributionDialog(page)).not.toContainText('Contribution confirmed');
await expect(contributionDialog(page)).not.toContainText('Gift sent');
});

test('a mobile confirmation timeout stays actionable without exposing raw wallet errors', async ({ page }, info) => {
await page.setViewportSize({ width: 320, height: 844 });
await openGift(page, '?e2eGift=timeout');
await review(page, '0.1');
await page.getByRole('button', { name: 'Confirm gift · 0.1 PAS', exact: true }).click();
const dialog = contributionDialog(page);
await expect(dialog).toContainText('Confirmation is taking longer than expected');
await expect(dialog).toContainText('Checking status verifies this payment. It never sends another.');
await expect(dialog.getByRole('button', { name: 'Check payment status' })).toBeVisible();
await expect(dialog.getByRole('link', { name: 'View transaction' })).toBeVisible();
const technicalError = dialog.locator('.contribution-technical code');
await expect(technicalError).not.toBeVisible();
await dialog.getByText('Technical details', { exact: true }).click();
await expect(technicalError).toBeVisible();
await expect(technicalError).toContainText('viem@2.55.19');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
const actions = dialog.locator('.contribution-result-actions');
expect((await actions.boundingBox())!.width).toBeLessThanOrEqual((await dialog.boundingBox())!.width);
await page.screenshot({ path: info.outputPath('tip-timeout-mobile-320.png'), animations: 'disabled' });
expect((await giftState(page)).sends).toBe(1);
});
116 changes: 76 additions & 40 deletions web/src/components/ArtistDonationButton.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Coins, HandHeart, X } from 'lucide-react';
import { CircleCheckBig, Coins, ExternalLink, HandHeart, X } from 'lucide-react';
import { useLayoutEffect, useRef, useState } from 'react';
import { formatEther, parseEther, parseAbi, zeroHash, type Address, type Hash } from 'viem';
import { musicRegistryAbi } from '../generated/contracts/musicRegistry';
import { contributionE2e } from '../e2e/contributionMock';
import { Dialog } from './Dialog';
import { useWalletContext, useSessionContext, useUiFeedback } from '../app/providers';
import type { CatalogTrack } from '../shared/types';
import { contributionReader, newContributionContext } from '../features/donations/contributions';
import { confirmSubmittedContribution, contributionReader, newContributionContext } from '../features/donations/contributions';
import { runContribution, type ContributionIntent, type ContributionOutcome } from '../features/donations/contributionFlow';
import { useContributionWriter } from '../features/donations/useContributionWriter';
import { nativeCurrencyForChain } from '../shared/config/contracts';
Expand All @@ -25,7 +25,7 @@ export function ArtistDonationButton(props: ContributionButtonProps) {
function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) {
const wallet = useWalletContext();
const session = useSessionContext();
const { openWalletModal } = useUiFeedback();
const { openWalletModal, pushNotice } = useUiFeedback();
const writer = useContributionWriter();
const [open, setOpen] = useState(false);
const [amount, setAmount] = useState('');
Expand All @@ -43,7 +43,11 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) {
};
}, [wallet.listenerEvmAddress]);
const label = kind === 'tip' ? 'Tip this track' : 'Send a gift';
const confirmed = outcome?.status === 'confirmed' && Boolean(outcome.receipt);
const actionLabel = confirmed ? (kind === 'tip' ? 'Tip sent. View receipt' : 'Gift sent. View receipt') : label;
const visibleActionLabel = confirmed ? (kind === 'tip' ? 'Tip sent' : 'Gift sent') : label;
const ContributionIcon = kind === 'tip' ? Coins : HandHeart;
const ActionIcon = confirmed ? CircleCheckBig : ContributionIcon;
const symbol = (wallet.expectedChainId ? nativeCurrencyForChain(wallet.expectedChainId, wallet.ethRpcUrl).symbol : '') || 'PAS';
async function review() {
if (busy) return;
Expand Down Expand Up @@ -153,20 +157,27 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) {
}
return writer.contributionCall!(intent.runtime, 'musicGiftContribute', [intent.context, intent.quote.digest, intent.proof], intent.amount);
},
confirm: async (hash, id) => {
try {
return await reader.receipt(intent.runtime, hash, id);
} catch (error) {
// Product can return a native extrinsic hash. The finalized event's
// intent identity also recovers its EVM receipt without another write.
const receipt = (await reader.history(intent.runtime)).find(row => row.id === id);
if (receipt) return receipt;
throw error;
}
},
confirm: (hash, id) =>
confirmSubmittedContribution({
mode: writer.contributionConfirmationMode,
runtime: intent.runtime,
hash,
id,
reader
}),
recover: async id => (await reader.history(intent.runtime)).find(row => row.id === id)
});
setOutcome(result);
if (result.status === 'confirmed' && result.receipt) {
pushNotice({
tone: 'success',
title: kind === 'tip' ? 'Tip sent' : 'Gift sent',
message:
kind === 'tip'
? `${formatEther(result.receipt.amount)} ${symbol} for “${track.title}”. The finalized receipt is ready.`
: `${formatEther(result.receipt.amount)} ${symbol} for ${track.artist}. The finalized receipt is ready.`
});
}
if (result.receipt && result.receipt.room !== zeroHash)
session.socketRef.current?.request(
'room:tip-notify',
Expand All @@ -185,19 +196,20 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) {
<button
className='secondary-action contribution-action'
data-kind={kind}
data-confirmed={confirmed || undefined}
type='button'
aria-label={label}
aria-label={actionLabel}
aria-haspopup='dialog'
onClick={event => {
event.currentTarget.focus();
setOpen(true);
}}
>
<ContributionIcon size={18} aria-hidden='true' />
<span className='contribution-action-label'>{label}</span>
<ActionIcon size={18} aria-hidden='true' />
<span className='contribution-action-label'>{visibleActionLabel}</span>
{kind === 'tip' && (
<span className='contribution-action-short' aria-hidden='true'>
Tip
{confirmed ? 'Sent' : 'Tip'}
</span>
)}
</button>
Expand Down Expand Up @@ -301,8 +313,22 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) {
)}
{busy && <p role='status'>You can close this window. Closing does not cancel a transaction.</p>}
{outcome && (
<div role='status'>
<p>{outcome.message}</p>
<div className='contribution-result' data-status={outcome.status} role='status' aria-live='polite'>
{outcome.status === 'confirmed' && outcome.receipt ? (
<div className='contribution-result-head'>
<span className='contribution-result-mark' aria-hidden='true'>
<CircleCheckBig size={24} />
</span>
<div>
<h3>{kind === 'tip' ? 'Tip sent' : 'Gift sent'}</h3>
<p>
{formatEther(outcome.receipt.amount)} {symbol} {kind === 'tip' ? `for “${track.title}”` : `for ${track.artist}`}. Finalized and recorded.
</p>
</div>
</div>
) : (
<p>{outcome.message}</p>
)}
{outcome.receipt && (
<>
<time dateTime={new Date(outcome.receipt.timestamp).toISOString()}>{new Date(outcome.receipt.timestamp).toLocaleString()}</time>
Expand All @@ -321,27 +347,37 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) {
</ul>
</>
)}
{outcome.hash && (
<a href={getBlockscoutTxUrl(outcome.hash)} target='_blank' rel='noreferrer'>
View transaction
</a>
)}
{outcome.status === 'uncertain' ? (
<button className='secondary-action' disabled={busy} onClick={() => void send()}>
Check status · no new payment
</button>
) : (
<button
className='secondary-action'
onClick={() => {
setOutcome(undefined);
setIntent(undefined);
setAmount('');
}}
>
Prepare another contribution
</button>
{outcome.technicalMessage && outcome.technicalMessage !== outcome.message && (
<details className='transaction-technical contribution-technical'>
<summary>Technical details</summary>
<code>{outcome.technicalMessage}</code>
</details>
)}
{outcome.status === 'uncertain' && <p className='contribution-recovery-note'>Checking status verifies this payment. It never sends another.</p>}
<div className='contribution-result-actions'>
{outcome.hash && (writer.contributionConfirmationMode === 'evm-receipt' || outcome.receipt) && (
<a className='secondary-action contribution-transaction-link' href={getBlockscoutTxUrl(outcome.hash)} target='_blank' rel='noreferrer'>
<ExternalLink size={16} aria-hidden='true' />
View transaction
</a>
)}
{outcome.status === 'uncertain' ? (
<button className='primary-action' disabled={busy} onClick={() => void send()}>
{busy ? 'Checking payment…' : 'Check payment status'}
</button>
) : (
<button
className='secondary-action'
onClick={() => {
setOutcome(undefined);
setIntent(undefined);
setAmount('');
}}
>
Send another {kind}
</button>
)}
</div>
</div>
)}
{error && <p role='alert'>{error}</p>}
Expand Down
10 changes: 9 additions & 1 deletion web/src/e2e/contributionMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ export const contributionTestReader = {
resolve();
};
});
if (scenario === 'delayed' && !s.confirmed) throw new Error('Confirmation was interrupted. Check the status before sending again.');
if ((scenario === 'delayed' || scenario === 'timeout') && !s.confirmed)
throw new Error(
scenario === 'timeout'
? `Timed out while waiting for transaction "${E2E_CLASSIC_TX_HASH}" to be confirmed. Version: viem@2.55.19`
: 'Confirmation was interrupted. Check the status before sending again.'
);
s.confirmed = true;
if (!s.receipt) throw new Error('No contribution');
return s.receipt;
Expand All @@ -55,6 +60,9 @@ export const contributionTestReader = {
if (s.historyError) throw new Error(s.historyError);
if (s.receipts) return s.receipts.filter(row => row.runtime.toLowerCase() === runtime.toLowerCase());
return s.confirmed && s.receipt?.runtime.toLowerCase() === runtime.toLowerCase() ? [s.receipt] : [];
},
async finalizedReceipt(runtime: Address, expectedId: Hash): Promise<ContributionReceipt | undefined> {
return (await contributionTestReader.history(runtime)).find(row => row.id === expectedId);
}
};
export async function contributionTestWrite(runtime: Address, method: string, args: readonly unknown[]) {
Expand Down
7 changes: 6 additions & 1 deletion web/src/features/donations/contributionFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ describe('persistent contributions', () => {
it('recovers after reload without resubmitting, even if the requested amount changed', async () => {
const input = fixture();
input.confirm.mockRejectedValueOnce(new Error('timeout'));
expect((await runContribution(input)).status).toBe('uncertain');
const interrupted = await runContribution(input);
expect(interrupted).toMatchObject({
status: 'uncertain',
message: expect.stringContaining('taking longer than expected'),
technicalMessage: 'timeout'
});
expect(input.storage.getItem(contributionStorageKey(input.intent))).toContain(input.receipt.transactionHash);
expect((await runContribution({ ...input, intent: { ...input.intent, amount: 99n } })).receipt?.amount).toBe(10n);
expect(input.send).toHaveBeenCalledTimes(1);
Expand Down
18 changes: 15 additions & 3 deletions web/src/features/donations/contributionFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ export type ContributionIntent = {
proof: Hash;
};
type Saved = { id: Hash; amount: string; hash?: Hash };
export type ContributionOutcome = { status: 'confirmed' | 'uncertain' | 'failed'; message: string; hash?: Hash; receipt?: ContributionReceipt };
export type ContributionOutcome = {
status: 'confirmed' | 'uncertain' | 'failed';
message: string;
technicalMessage?: string;
hash?: Hash;
receipt?: ContributionReceipt;
};
const operations = new Map<string, Promise<ContributionOutcome>>();

export function contributionStorageKey(intent: ContributionIntent) {
Expand Down Expand Up @@ -65,10 +71,16 @@ export async function runContribution(input: {
/* Preserve an uncertain reservation if storage is no longer writable. */
}
}
const status = saved && !safe ? 'uncertain' : 'failed';
const technicalMessage = error instanceof Error ? error.message : 'The contribution could not be checked.';
return {
status: saved && !safe ? 'uncertain' : 'failed',
status,
hash: saved?.hash,
message: error instanceof Error ? error.message : 'The contribution could not be checked.'
message:
status === 'uncertain'
? 'Confirmation is taking longer than expected. This contribution may already be finalized. Check its status before trying again.'
: technicalMessage,
technicalMessage: status === 'uncertain' ? technicalMessage : undefined
};
}
};
Expand Down
Loading
Loading