diff --git a/packages/connect-a-wallet-widget/src/components/AddressLinkForm.tsx b/packages/connect-a-wallet-widget/src/components/AddressLinkForm.tsx index 4a058de2..60ece13f 100644 --- a/packages/connect-a-wallet-widget/src/components/AddressLinkForm.tsx +++ b/packages/connect-a-wallet-widget/src/components/AddressLinkForm.tsx @@ -17,8 +17,8 @@ export function AddressLinkForm({ }: AddressLinkFormProps) { return ( - Wallet address to link - + Connect or Disconnect Address + Enter the address you want to connect to or disconnect from your GoodID, then check its status on each supported chain. @@ -27,9 +27,10 @@ export function AddressLinkForm({ onChangeText={onChangeAddressInput} placeholder="0x…" disabled={isChecking} + size="sm" /> - - {isChecking ? : Check address} + + {isChecking ? : Check address} ) diff --git a/packages/connect-a-wallet-widget/src/components/ChainLinkRow.tsx b/packages/connect-a-wallet-widget/src/components/ChainLinkRow.tsx index c0d41eeb..c341850c 100644 --- a/packages/connect-a-wallet-widget/src/components/ChainLinkRow.tsx +++ b/packages/connect-a-wallet-widget/src/components/ChainLinkRow.tsx @@ -1,12 +1,11 @@ -import React from 'react' -import { AddressDisplay, Badge, BadgeText, ButtonText, ChainBadge, Spinner } from '@goodwidget/ui' +import React, { useState } from 'react' +import { ButtonText, Icon, Spinner, Stack, Text, XStack, YStack } from '@goodwidget/ui' import type { ConnectAWalletChainLinkState } from '../widgetRuntimeContract' import { chainLinkRowPresentation } from './format' -import { ActionButton, ChainRowCard } from './shared' +import { ActionButton } from './shared' interface ChainLinkRowProps { row: ConnectAWalletChainLinkState - address: `0x${string}` onConnect: () => void onDisconnect: () => void } @@ -16,24 +15,109 @@ interface ChainLinkRowProps { * Disconnect (never hidden) with a Spinner while a status is in flight, per * Bounty Lead sign-off on the human-reviewer checklist. */ -export function ChainLinkRow({ row, address, onConnect, onDisconnect }: ChainLinkRowProps) { +export function ChainLinkRow({ row, onConnect, onDisconnect }: ChainLinkRowProps) { const { actionLabel, isBusy, isDisabled } = chainLinkRowPresentation(row.status) const handlePress = actionLabel === 'Connect' ? onConnect : onDisconnect + const [disconnectHovered, setDisconnectHovered] = useState(false) + + // Map status to dot color and display text + let dotColor = '$placeholderColor' + let statusText = 'Not Connected' + + if (row.status === 'connected') { + dotColor = '$success' + statusText = 'Connected' + } else if (row.status === 'connecting') { + statusText = 'Connecting...' + } else if (row.status === 'disconnecting') { + statusText = 'Disconnecting...' + } else if (row.status === 'checking') { + statusText = 'Checking...' + } + + const isDisconnect = actionLabel === 'Disconnect' return ( - - - - - {row.status === 'checking' ? 'checking…' : row.status.replace('_', ' ')} - - - {isBusy ? : {actionLabel}} - - + + + + + {row.chainName.charAt(0)} + + + + + + {row.chainName} + + + + + {statusText} + + + + + + {isBusy ? ( + + + + ) : isDisconnect ? ( + setDisconnectHovered(true)} + onMouseLeave={() => setDisconnectHovered(false)} + > + + + Disconnect + + + ) : ( + + + + Connect + + + )} + ) } diff --git a/packages/connect-a-wallet-widget/src/components/ConnectAWalletWidgetView.tsx b/packages/connect-a-wallet-widget/src/components/ConnectAWalletWidgetView.tsx index 01a100d2..adb7a29f 100644 --- a/packages/connect-a-wallet-widget/src/components/ConnectAWalletWidgetView.tsx +++ b/packages/connect-a-wallet-widget/src/components/ConnectAWalletWidgetView.tsx @@ -1,5 +1,17 @@ import React from 'react' -import { Alert, Badge, BadgeText, ButtonText, Heading, Spinner, XStack } from '@goodwidget/ui' +import { + AddressDisplay, + Alert, + ButtonText, + Card, + Heading, + Icon, + Spinner, + Stack, + Text, + XStack, + YStack, +} from '@goodwidget/ui' import type { ConnectAWalletWidgetAdapterResult } from '../widgetRuntimeContract' import { AddressLinkForm } from './AddressLinkForm' import { ChainLinkRow } from './ChainLinkRow' @@ -7,6 +19,12 @@ import { PrimaryIdentityCard } from './PrimaryIdentityCard' import { ActionButton, EmptyStateCard, WidgetContent } from './shared' import { WalletGate } from './WalletGate' +const CHAIN_NAMES: Record = { + 122: 'Fuse', + 42220: 'Celo', + 50: 'XDC', +} + interface ConnectAWalletWidgetViewProps { adapter: ConnectAWalletWidgetAdapterResult 'data-testid'?: string @@ -22,16 +40,61 @@ export function ConnectAWalletWidgetView({ // header rather than relying on the host shell for it (see // StreamingWidgetView) — matches the #113 design reference's top bar. const header = ( - - GoodDollar - {state.activeChainId && ( - - Chain {state.activeChainId} - - )} + + + GoodDollar + {state.activeChainId && ( + + + {CHAIN_NAMES[state.activeChainId] ?? `Chain ${state.activeChainId}`} + + + )} + + + + Connect identity + + + + + ) + + const infoCallout = ( + + + + + You can connect multiple wallet addresses. + + + However, only one claim per day is available, shared between the connected accounts. + + ) + const footer = ( + + Supported Networks: Celo, XDC, Fuse + + ) + if (!state.isWalletConnected) { return ( @@ -45,10 +108,14 @@ export function ConnectAWalletWidgetView({ ) } + const showForm = state.status !== 'error' && !(state.status === 'ready' && state.secondaryAddress) + return ( {header} + {infoCallout} + {!state.isActiveChainSupported && ( @@ -61,17 +128,17 @@ export function ConnectAWalletWidgetView({ {state.status === 'error' && ( - + Couldn't load link status - - Retry + + Retry )} - {state.status !== 'error' && ( + {showForm && ( - {state.chainLinks.map((row) => ( - actions.connectChain(row.chainId)} - onDisconnect={() => actions.disconnectChain(row.chainId)} - /> - ))} + Connect or Disconnect Address + + + + + + + + + + + {state.chainLinks.map((row) => ( + actions.connectChain(row.chainId)} + onDisconnect={() => actions.disconnectChain(row.chainId)} + /> + ))} + )} + + {footer} ) } diff --git a/packages/connect-a-wallet-widget/src/components/PrimaryIdentityCard.tsx b/packages/connect-a-wallet-widget/src/components/PrimaryIdentityCard.tsx index 75e150b3..9f0e0240 100644 --- a/packages/connect-a-wallet-widget/src/components/PrimaryIdentityCard.tsx +++ b/packages/connect-a-wallet-widget/src/components/PrimaryIdentityCard.tsx @@ -1,25 +1,5 @@ -import React from 'react' -import { AddressDisplay, Icon, Text, XStack, YStack, createComponent } from '@goodwidget/ui' - -const IdentityIconBadge = createComponent(YStack, { - name: 'PrimaryIdentityIconBadge', - width: 36, - height: 36, - borderRadius: '$full', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '$infoMuted', -}) - -const IdentityCard = createComponent(XStack, { - name: 'PrimaryIdentityCard', - alignItems: 'center', - gap: '$3', - padding: '$3', - borderRadius: '$3', - borderWidth: 1, - borderColor: '$borderColor', -}) +import React, { useState } from 'react' +import { AddressDisplay, GlowCard, Icon, Text, XStack, YStack, copyTextToClipboard } from '@goodwidget/ui' interface PrimaryIdentityCardProps { walletAddress: string | null @@ -31,21 +11,61 @@ interface PrimaryIdentityCardProps { * the "Primary Verified Identity" card in the #113 design reference. */ export function PrimaryIdentityCard({ walletAddress }: PrimaryIdentityCardProps) { + const [copied, setCopied] = useState(false) + if (!walletAddress) { return null } + const handleCopy = async () => { + const success = await copyTextToClipboard(walletAddress) + if (success) { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + return ( - - - - - - - Primary verified identity - - - - + + + + + + + + Primary Verified Identity + + + + + + + + ) } diff --git a/packages/connect-a-wallet-widget/src/components/shared.tsx b/packages/connect-a-wallet-widget/src/components/shared.tsx index a7924a07..1b1b8d87 100644 --- a/packages/connect-a-wallet-widget/src/components/shared.tsx +++ b/packages/connect-a-wallet-widget/src/components/shared.tsx @@ -22,6 +22,7 @@ export const ChainRowCard = createComponent(Card, { flexDirection: 'row' as const, alignItems: 'center' as const, justifyContent: 'space-between' as const, + flexWrap: 'wrap' as const, gap: '$2', }) @@ -31,9 +32,9 @@ export const AddressFormCard = createComponent(Card, { gap: '$3', }) -export function ActionButton({ children, minWidth = 108, size = 'sm', ...props }: ButtonProps) { +export function ActionButton({ children, size = 'sm', ...props }: ButtonProps) { return ( - ) diff --git a/packages/ui/src/components/GlowCard.ts b/packages/ui/src/components/GlowCard.ts index e169cfe0..66e55c28 100644 --- a/packages/ui/src/components/GlowCard.ts +++ b/packages/ui/src/components/GlowCard.ts @@ -9,6 +9,6 @@ export const GlowCard = createComponent(Card, { borderColor: '$borderColorFocus', shadowColor: '$borderColorFocus', shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 1, - shadowRadius: 34, + shadowOpacity: 0.4, + shadowRadius: 12, }) diff --git a/packages/ui/src/components/Icon.tsx b/packages/ui/src/components/Icon.tsx index 19ecfcbc..15a7ee80 100644 --- a/packages/ui/src/components/Icon.tsx +++ b/packages/ui/src/components/Icon.tsx @@ -56,6 +56,18 @@ const SVG_PATHS: Record = { copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z', wallet: 'M20 7H4a2 2 0 00-2 2v10a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2zM16 14a1 1 0 110-2 1 1 0 010 2zM4 7V5a2 2 0 012-2h12a2 2 0 012 2v2', 'external-link': 'M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3', + link: [ + 'M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71', + 'M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71', + ], + unlink: [ + 'm18.84 12.25 1.72-1.71a5.006 5.006 0 00-7.07-7.07l-1.72 1.71', + 'm5.17 11.75-1.72 1.71a5.006 5.006 0 007.07 7.07l1.71-1.71', + 'M8 2v3', + 'M2 8h3', + 'M16 19v3', + 'M19 16h3', + ], search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z', refresh: ['M23 4v6h-6', 'M20.49 15a9 9 0 1 1-2.12-9.36L23 10'], settings: 'M12 15a3 3 0 100-6 3 3 0 000 6zM19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z', diff --git a/tests/widgets/connect-a-wallet-widget/states.spec.ts b/tests/widgets/connect-a-wallet-widget/states.spec.ts index 7fb923f7..eff21ac5 100644 --- a/tests/widgets/connect-a-wallet-widget/states.spec.ts +++ b/tests/widgets/connect-a-wallet-widget/states.spec.ts @@ -9,7 +9,19 @@ function storyUrl(storyId: string): string { async function gotoStory(page: Page, storyId: string): Promise { await page.goto(storyUrl(storyId)) await page.waitForLoadState('domcontentloaded') - await page.waitForFunction(() => document.body.innerText.trim().length > 0) +} + +/** Poll until any of the given strings appears in the body text. */ +async function waitForText(page: Page, patterns: string[], timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const text = await page.evaluate(() => document.body.innerText) + for (const p of patterns) { + if (text.includes(p)) return p + } + await page.waitForTimeout(250) + } + return '' } async function bodyText(page: Page): Promise { @@ -37,6 +49,8 @@ async function saveScreenshot(page: Page, name: string) { test('ConnectAWalletWidget shows the disconnected wallet gate', async ({ page }) => { await gotoStory(page, 'not-connected') + const matched = await waitForText(page, ['Wallet not connected', 'Connect Wallet']) + expect(matched, 'Widget must mount and render before screenshot').toBeTruthy() await expectBodyToContain(page, [ 'Wallet not connected', 'Connect your wallet to link additional addresses to your GoodID.', @@ -48,6 +62,8 @@ test('ConnectAWalletWidget shows the disconnected wallet gate', async ({ page }) test('ConnectAWalletWidget shows the host wallet connecting state', async ({ page }) => { await gotoStory(page, 'connecting') + const matched = await waitForText(page, ['Wallet not connected']) + expect(matched, 'Widget must mount and render before screenshot').toBeTruthy() await expectBodyToContain(page, ['Wallet not connected']) // Connect Wallet button swaps its label for a Spinner while connecting — never both. await expect(page.getByText('Connect Wallet', { exact: true })).toHaveCount(0) @@ -57,8 +73,10 @@ test('ConnectAWalletWidget shows the host wallet connecting state', async ({ pag test('ConnectAWalletWidget shows the connected-no-input address form', async ({ page }) => { await gotoStory(page, 'connected-no-input') + const matched = await waitForText(page, ['Connect or Disconnect Address', 'Check address']) + expect(matched, 'Widget must mount and render before screenshot').toBeTruthy() await expectBodyToContain(page, [ - 'Wallet address to link', + 'Connect or Disconnect Address', 'Enter the address you want to connect to or disconnect from your GoodID, then check its status on each supported chain.', 'Check address', ]) @@ -68,7 +86,9 @@ test('ConnectAWalletWidget shows the connected-no-input address form', async ({ test('ConnectAWalletWidget shows the checking-address loading state', async ({ page }) => { await gotoStory(page, 'checking-address') - await expectBodyToContain(page, ['Wallet address to link']) + const matched = await waitForText(page, ['Connect or Disconnect Address']) + expect(matched, 'Widget must mount and render before screenshot').toBeTruthy() + await expectBodyToContain(page, ['Connect or Disconnect Address']) // The Check address button label is replaced by a Spinner while checking. await expect(page.getByText('Check address', { exact: true })).toHaveCount(0) await saveScreenshot(page, 'caw-04-checking-address') @@ -77,7 +97,9 @@ test('ConnectAWalletWidget shows the checking-address loading state', async ({ p test('ConnectAWalletWidget always shows Connect or Disconnect per row, never hidden', async ({ page }) => { await gotoStory(page, 'ready-mixed-row-statuses') - await expectBodyToContain(page, ['Fuse', 'Celo', 'XDC', 'connected', 'connecting', 'disconnecting']) + const matched = await waitForText(page, ['Fuse', 'Celo', 'XDC']) + expect(matched, 'Widget must mount and render before screenshot').toBeTruthy() + await expectBodyToContain(page, ['Fuse', 'Celo', 'XDC', 'Connected', 'Connecting', 'Disconnecting']) // Connected row is idle and shows its action label; connecting/disconnecting rows are busy // and swap their label for a Spinner (see ChainLinkRow), so only the idle row's action text is @@ -94,17 +116,35 @@ test('ConnectAWalletWidget always shows Connect or Disconnect per row, never hid test('ConnectAWalletWidget shows the unsupported-network warning alongside chain rows', async ({ page }) => { await gotoStory(page, 'unsupported-network') + const matched = await waitForText(page, ['Unsupported network', 'Connect or Disconnect Address']) + expect(matched, 'Widget must mount and render before screenshot').toBeTruthy() await expectBodyToContain(page, [ 'Unsupported network', "Your wallet is on a network this widget doesn't support yet. Connecting or disconnecting a chain below will prompt a network switch automatically.", - 'Wallet address to link', + 'Connect or Disconnect Address', ]) await saveScreenshot(page, 'caw-06-unsupported-network') }) +test('ConnectAWalletWidget renders usable mobile and desktop layouts', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await gotoStory(page, 'ready-mixed-row-statuses') + const mobileMatched = await waitForText(page, ['Fuse', 'Celo', 'XDC']) + expect(mobileMatched, 'Widget must mount on mobile before screenshot').toBeTruthy() + await saveScreenshot(page, 'caw-08-mobile-ready') + + await page.setViewportSize({ width: 1280, height: 900 }) + await gotoStory(page, 'ready-mixed-row-statuses') + const desktopMatched = await waitForText(page, ['Fuse', 'Celo', 'XDC']) + expect(desktopMatched, 'Widget must mount on desktop before screenshot').toBeTruthy() + await saveScreenshot(page, 'caw-09-desktop-ready') +}) + test('ConnectAWalletWidget shows the top-level error state with retry', async ({ page }) => { await gotoStory(page, 'top-level-error-with-retry') + const matched = await waitForText(page, ["Couldn't load link status", 'Retry']) + expect(matched, 'Widget must mount and render before screenshot').toBeTruthy() await expectBodyToContain(page, [ "Couldn't load link status", 'Unable to reach the network. Check your connection and try again.', diff --git a/tests/widgets/connect-a-wallet-widget/test-results/caw-01-not-connected.png b/tests/widgets/connect-a-wallet-widget/test-results/caw-01-not-connected.png index 0cf5ec68..4c6f1e47 100644 Binary files a/tests/widgets/connect-a-wallet-widget/test-results/caw-01-not-connected.png and b/tests/widgets/connect-a-wallet-widget/test-results/caw-01-not-connected.png differ diff --git a/tests/widgets/connect-a-wallet-widget/test-results/caw-02-connecting.png b/tests/widgets/connect-a-wallet-widget/test-results/caw-02-connecting.png index 1b936957..dcebbdff 100644 Binary files a/tests/widgets/connect-a-wallet-widget/test-results/caw-02-connecting.png and b/tests/widgets/connect-a-wallet-widget/test-results/caw-02-connecting.png differ diff --git a/tests/widgets/connect-a-wallet-widget/test-results/caw-03-connected-no-input.png b/tests/widgets/connect-a-wallet-widget/test-results/caw-03-connected-no-input.png index e2125306..c64f77dc 100644 Binary files a/tests/widgets/connect-a-wallet-widget/test-results/caw-03-connected-no-input.png and b/tests/widgets/connect-a-wallet-widget/test-results/caw-03-connected-no-input.png differ diff --git a/tests/widgets/connect-a-wallet-widget/test-results/caw-04-checking-address.png b/tests/widgets/connect-a-wallet-widget/test-results/caw-04-checking-address.png index c9705e6f..b2d2cf68 100644 Binary files a/tests/widgets/connect-a-wallet-widget/test-results/caw-04-checking-address.png and b/tests/widgets/connect-a-wallet-widget/test-results/caw-04-checking-address.png differ diff --git a/tests/widgets/connect-a-wallet-widget/test-results/caw-05-ready-mixed-row-statuses.png b/tests/widgets/connect-a-wallet-widget/test-results/caw-05-ready-mixed-row-statuses.png index d7db52a0..74c7165b 100644 Binary files a/tests/widgets/connect-a-wallet-widget/test-results/caw-05-ready-mixed-row-statuses.png and b/tests/widgets/connect-a-wallet-widget/test-results/caw-05-ready-mixed-row-statuses.png differ diff --git a/tests/widgets/connect-a-wallet-widget/test-results/caw-06-unsupported-network.png b/tests/widgets/connect-a-wallet-widget/test-results/caw-06-unsupported-network.png index 573d7f1d..2b1b76b2 100644 Binary files a/tests/widgets/connect-a-wallet-widget/test-results/caw-06-unsupported-network.png and b/tests/widgets/connect-a-wallet-widget/test-results/caw-06-unsupported-network.png differ diff --git a/tests/widgets/connect-a-wallet-widget/test-results/caw-07-top-level-error-with-retry.png b/tests/widgets/connect-a-wallet-widget/test-results/caw-07-top-level-error-with-retry.png index 8e5d1b26..1619345c 100644 Binary files a/tests/widgets/connect-a-wallet-widget/test-results/caw-07-top-level-error-with-retry.png and b/tests/widgets/connect-a-wallet-widget/test-results/caw-07-top-level-error-with-retry.png differ diff --git a/tests/widgets/connect-a-wallet-widget/test-results/caw-08-mobile-ready.png b/tests/widgets/connect-a-wallet-widget/test-results/caw-08-mobile-ready.png new file mode 100644 index 00000000..7a72fcaa Binary files /dev/null and b/tests/widgets/connect-a-wallet-widget/test-results/caw-08-mobile-ready.png differ diff --git a/tests/widgets/connect-a-wallet-widget/test-results/caw-09-desktop-ready.png b/tests/widgets/connect-a-wallet-widget/test-results/caw-09-desktop-ready.png new file mode 100644 index 00000000..32aacbbc Binary files /dev/null and b/tests/widgets/connect-a-wallet-widget/test-results/caw-09-desktop-ready.png differ