@zerodev/smart-routing-address-react-ui 0.0.3 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/_types/components/FeeBreakdown/index.d.ts +14 -0
  2. package/dist/_types/components/FeeBreakdown/index.d.ts.map +1 -1
  3. package/dist/_types/components/QrSheet/index.d.ts.map +1 -1
  4. package/dist/_types/components/TxnItem/index.d.ts +1 -1
  5. package/dist/_types/components/TxnItem/index.d.ts.map +1 -1
  6. package/dist/_types/constants.d.ts +8 -5
  7. package/dist/_types/constants.d.ts.map +1 -1
  8. package/dist/_types/context/SmartRoutingAddressProvider.d.ts.map +1 -1
  9. package/dist/_types/iconAssets.d.ts +0 -1
  10. package/dist/_types/iconAssets.d.ts.map +1 -1
  11. package/dist/_types/pages/Deposit.d.ts.map +1 -1
  12. package/dist/_types/pages/PastDeposits.d.ts.map +1 -1
  13. package/dist/_types/pages/TransactionDetails.d.ts +4 -3
  14. package/dist/_types/pages/TransactionDetails.d.ts.map +1 -1
  15. package/dist/_types/types.d.ts +4 -7
  16. package/dist/_types/types.d.ts.map +1 -1
  17. package/dist/_types/utils/config.d.ts +11 -9
  18. package/dist/_types/utils/config.d.ts.map +1 -1
  19. package/dist/index.cjs +1 -1
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.mjs +1162 -1255
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/styles.css +1 -1
  24. package/package.json +5 -5
  25. package/src/components/FeeBreakdown/index.tsx +47 -2
  26. package/src/components/PendingDeposits/index.tsx +1 -1
  27. package/src/components/QrSheet/index.tsx +3 -12
  28. package/src/components/TxnItem/index.tsx +1 -9
  29. package/src/constants.ts +14 -25
  30. package/src/context/SmartRoutingAddressProvider.tsx +12 -10
  31. package/src/iconAssets.ts +27 -0
  32. package/src/pages/Deposit.tsx +16 -26
  33. package/src/pages/PastDeposits.tsx +1 -4
  34. package/src/pages/TransactionDetails.tsx +58 -49
  35. package/src/types.ts +4 -7
  36. package/src/utils/config.ts +14 -26
  37. package/dist/_types/components/QrSheet/QrCode.d.ts +0 -28
  38. package/dist/_types/components/QrSheet/QrCode.d.ts.map +0 -1
  39. package/src/components/QrSheet/QrCode.tsx +0 -163
@@ -21,6 +21,7 @@ import { AddressDisplay } from '../components/AddressDisplay'
21
21
  import { ErrorRetryCard } from '../components/ErrorRetryCard'
22
22
  import {
23
23
  FeeBreakdownRows,
24
+ FeeDisclosureButton,
24
25
  FeeSummary,
25
26
  LiveValue,
26
27
  } from '../components/FeeBreakdown'
@@ -36,7 +37,6 @@ import { CHAIN_ICONS, PROVIDER_ICONS, TOKEN_ICONS } from '../iconAssets'
36
37
  import type { SourceToken } from '../types'
37
38
  import {
38
39
  getSourceTokenSymbol,
39
- resolveBaseUrl,
40
40
  resolveDestChain,
41
41
  sourceTokensFromFees,
42
42
  } from '../utils/config'
@@ -82,7 +82,7 @@ export function Deposit({
82
82
  const address = success?.address
83
83
  const estimatedFees = success?.estimatedFees ?? []
84
84
 
85
- // allowPartialRoutes lets the server drop source tokens it can't route, so
85
+ // The server only returns estimates for source tokens it can route, so
86
86
  // the routable set is exactly the tokens the fee estimates came back with.
87
87
  const srcTokens = useMemo(
88
88
  () => sourceTokensFromFees(estimatedFees),
@@ -136,7 +136,7 @@ export function Deposit({
136
136
  refetch: refetchDeposits,
137
137
  } = useDepositStatus({
138
138
  address,
139
- baseUrl: resolveBaseUrl(config),
139
+ baseUrl: config.baseUrl,
140
140
  })
141
141
  const newDeposits = useNewDeposits(deposits, hasLoaded)
142
142
  const pastDepositsCount = deposits.length - newDeposits.length
@@ -417,12 +417,19 @@ export function Deposit({
417
417
  label="Estimated fee"
418
418
  value={
419
419
  breakdown ? (
420
- <LiveValue
421
- loading={providerFees.loading}
422
- flashKey={feeFlashKey}
420
+ // The whole value (summary + chevron) toggles the
421
+ // breakdown, not just the arrow.
422
+ <FeeDisclosureButton
423
+ open={feeOpen}
424
+ onToggle={() => setFeeOpen((prev) => !prev)}
423
425
  >
424
- <FeeSummary breakdown={breakdown} />
425
- </LiveValue>
426
+ <LiveValue
427
+ loading={providerFees.loading}
428
+ flashKey={feeFlashKey}
429
+ >
430
+ <FeeSummary breakdown={breakdown} />
431
+ </LiveValue>
432
+ </FeeDisclosureButton>
426
433
  ) : (
427
434
  // Match Min deposit's skeleton so both loading
428
435
  // affordances share one visual language.
@@ -431,24 +438,6 @@ export function Deposit({
431
438
  }
432
439
  info
433
440
  infoTooltip={FEE_INFO.estimatedFee}
434
- trailing={
435
- breakdown ? (
436
- <button
437
- type="button"
438
- onClick={() => setFeeOpen((prev) => !prev)}
439
- aria-expanded={feeOpen}
440
- aria-label={
441
- feeOpen ? 'Hide fee details' : 'Show fee details'
442
- }
443
- className="zd:inline-flex zd:items-center zd:justify-center zd:cursor-pointer"
444
- >
445
- <Icon
446
- name={feeOpen ? 'chevronUp' : 'chevronDown'}
447
- className="zd:w-3.5 zd:h-3.5 zd:text-greyScale"
448
- />
449
- </button>
450
- ) : null
451
- }
452
441
  />
453
442
  {feeOpen && breakdown && (
454
443
  <FeeBreakdownRows breakdown={breakdown} />
@@ -476,6 +465,7 @@ export function Deposit({
476
465
  label={destChain.name}
477
466
  {...(destChainLogo && { logoUri: destChainLogo })}
478
467
  disabled
468
+ loading={!tokenSymbol}
479
469
  />
480
470
  }
481
471
  />
@@ -9,7 +9,6 @@ import type { DepositStage, DepositWithTimestamp } from '../types'
9
9
  import { getTxUrl } from '../utils/chains'
10
10
  import {
11
11
  getSourceTokenSymbol,
12
- resolveBaseUrl,
13
12
  resolveDestChain,
14
13
  sourceTokensFromFees,
15
14
  } from '../utils/config'
@@ -24,8 +23,6 @@ import {
24
23
  const STAGE_TO_STATUS: Record<DepositStage, TxnStatus> = {
25
24
  pending: 'Detected',
26
25
  bridging: 'Routing',
27
- // Past deposits use the terminal label — "Received" (plain ink) is the
28
- // active list's arrival state; "Delivered" renders green.
29
26
  completed: 'Delivered',
30
27
  failed: 'Failed',
31
28
  }
@@ -93,7 +90,7 @@ export function PastDeposits({ onSelectDeposit }: PastDepositsProps) {
93
90
 
94
91
  const { deposits, hasLoaded } = useDepositStatus({
95
92
  address,
96
- baseUrl: resolveBaseUrl(config),
93
+ baseUrl: config.baseUrl,
97
94
  })
98
95
 
99
96
  const destChain = resolveDestChain(config)
@@ -1,15 +1,19 @@
1
1
  import {
2
- ArrowCardPair,
3
2
  cn,
4
3
  DataRow,
5
4
  Icon,
6
- InfoCard,
7
5
  ProgressStep,
8
6
  Section,
9
7
  Text,
8
+ TokenSummary,
9
+ Wrapper,
10
10
  } from '@zerodev/react-ui'
11
11
  import { useState } from 'react'
12
- import { FeeBreakdownRows, FeeSummary } from '../components/FeeBreakdown'
12
+ import {
13
+ FeeBreakdownRows,
14
+ FeeDisclosureButton,
15
+ FeeSummary,
16
+ } from '../components/FeeBreakdown'
13
17
  import { FEE_INFO } from '../components/FeeBreakdown/feeInfo'
14
18
  import { useSmartRoutingAddressContext } from '../context/SmartRoutingAddressContext'
15
19
  import { useDepositStatus } from '../hooks/useDepositStatus'
@@ -19,7 +23,6 @@ import type { DepositWithTimestamp } from '../types'
19
23
  import { getTxUrl } from '../utils/chains'
20
24
  import {
21
25
  getSourceTokenSymbol,
22
- resolveBaseUrl,
23
26
  resolveDestChain,
24
27
  sourceTokensFromFees,
25
28
  } from '../utils/config'
@@ -45,7 +48,7 @@ function useLiveDeposit(initial: DepositWithTimestamp): DepositWithTimestamp {
45
48
  addressState.status === 'success' ? addressState.address : undefined
46
49
  const { deposits } = useDepositStatus({
47
50
  address,
48
- baseUrl: resolveBaseUrl(config),
51
+ baseUrl: config.baseUrl,
49
52
  })
50
53
  const targetHash = initial.deposit.transactionHash
51
54
  const live = deposits.find((d) => d.deposit.transactionHash === targetHash) as
@@ -72,9 +75,10 @@ function formatDate(iso?: string): string | null {
72
75
  }
73
76
 
74
77
  /**
75
- * Transaction details view (Figma `17634:105049`) — token hero pair via
76
- * `ArrowCardPair` + `InfoCard`, a "Transaction details" `Section` with the
77
- * route metadata, and a "Transaction Progress" `Section` with the step trail.
78
+ * Transaction details view (Figma `20002:37994`) — delivered-amount hero via
79
+ * `TokenSummary`, a "From" row with the deposited amount, a "Transaction
80
+ * details" `Section` with the route metadata, and a "Transaction Progress"
81
+ * `Section` with the step trail.
78
82
  */
79
83
  export function TransactionDetails({
80
84
  deposit: initialDeposit,
@@ -117,11 +121,11 @@ export function TransactionDetails({
117
121
  const outAmountRaw = execution?.outputAmount
118
122
  const failed = stage === 'failed'
119
123
 
120
- // Headline reads as "how much you sent" — round down so we never overstate
121
- // the deposit. `formatDisplayAmount` (magnitude-aware) also prevents the
124
+ // "From" row amount — round down so we never overstate what was sent.
125
+ // `formatDisplayAmount` (magnitude-aware) also prevents the
122
126
  // 18-decimal fractional tail that `formatTokenAmount` produces for ETH.
123
- const sourceHeadline = feeData
124
- ? `${formatDisplayAmount(amount, feeData.decimal, 'down')} ${sourceSymbol}`
127
+ const sourceAmount = feeData
128
+ ? formatDisplayAmount(amount, feeData.decimal, 'down')
125
129
  : String(amount)
126
130
 
127
131
  // NOTE: `feeData.decimal` is the source token's decimals — the SRA fee
@@ -164,23 +168,36 @@ export function TransactionDetails({
164
168
  // Screen's scroll container can activate scrolling when the fee
165
169
  // breakdown is expanded and content exceeds the viewport.
166
170
  <div className="zd:flex zd:w-full zd:flex-col zd:gap-3 zd:pt-4 zd:pb-6">
167
- <ArrowCardPair
168
- topCard={
169
- <InfoCard
170
- title={sourceHeadline}
171
- {...(sourceTokenLogo && { imageSource: sourceTokenLogo })}
172
- {...(sourceChainLogo && { chainIconUrl: sourceChainLogo })}
173
- />
174
- }
175
- bottomCard={
176
- <InfoCard
177
- title={destHeadline}
178
- {...(destTokenLogo && { imageSource: destTokenLogo })}
179
- {...(destChainLogo && { chainIconUrl: destChainLogo })}
180
- />
181
- }
171
+ {/* Delivered hero (Figma 20002:38000): the received amount headlines
172
+ the card, destination-token tile with chain badge overhanging the
173
+ top. The design's fiat secondary line is omitted — the SRA fee
174
+ estimates carry no USD pricing, and a fabricated conversion would
175
+ be worse than none. */}
176
+ <TokenSummary
177
+ primaryValue={destHeadline}
178
+ {...(destTokenLogo && { tokenLogoUrl: destTokenLogo })}
179
+ {...(destChainLogo && { badgeLogoUrl: destChainLogo })}
182
180
  />
183
181
 
182
+ {/* Deposited-side row (Figma 20002:38012). */}
183
+ {/* py-1.5, not the node's declared p-16: Figma pins this row to
184
+ ~33px total height, which crushes the vertical padding to ~6px —
185
+ honouring p-16 literally renders a ~53px row. */}
186
+ <Wrapper className="zd:flex zd:w-full zd:items-center zd:justify-between zd:rounded-xl zd:px-4 zd:py-1.5">
187
+ <Text className="zd:text-body1">From</Text>
188
+ <span className="zd:flex zd:items-center zd:gap-1">
189
+ <Text className="zd:text-body2">{sourceAmount}</Text>
190
+ {sourceTokenLogo && (
191
+ <img
192
+ src={sourceTokenLogo}
193
+ alt=""
194
+ className="zd:size-4 zd:rounded-full zd:border zd:border-offWhite zd:object-cover"
195
+ />
196
+ )}
197
+ <Text className="zd:text-body2">{sourceSymbol}</Text>
198
+ </span>
199
+ </Wrapper>
200
+
184
201
  <Section title="Transaction details">
185
202
  <DataRow
186
203
  label="From network"
@@ -204,24 +221,19 @@ export function TransactionDetails({
204
221
  {breakdown && (
205
222
  <DataRow
206
223
  label="Total fee"
207
- value={<FeeSummary breakdown={breakdown} />}
208
- info
209
- infoTooltip={FEE_INFO.estimatedFee}
210
- trailing={
211
- <button
212
- type="button"
213
- onClick={() => setFeeOpen((prev) => !prev)}
214
- aria-expanded={feeOpen}
215
- aria-controls={FEE_PANEL_ID}
216
- aria-label={feeOpen ? 'Hide fee details' : 'Show fee details'}
217
- className="zd:inline-flex zd:items-center zd:justify-center zd:cursor-pointer"
224
+ value={
225
+ // The whole value (summary + chevron) toggles the breakdown,
226
+ // not just the arrow.
227
+ <FeeDisclosureButton
228
+ open={feeOpen}
229
+ onToggle={() => setFeeOpen((prev) => !prev)}
230
+ panelId={FEE_PANEL_ID}
218
231
  >
219
- <Icon
220
- name={feeOpen ? 'chevronUp' : 'chevronDown'}
221
- className="zd:w-3.5 zd:h-3.5 zd:text-greyScale"
222
- />
223
- </button>
232
+ <FeeSummary breakdown={breakdown} />
233
+ </FeeDisclosureButton>
224
234
  }
235
+ info
236
+ infoTooltip={FEE_INFO.estimatedFee}
225
237
  />
226
238
  )}
227
239
  {feeOpen && breakdown && (
@@ -301,7 +313,7 @@ function NetworkValue({ name, logoUrl }: { name: string; logoUrl?: string }) {
301
313
  <span className="zd:inline-flex zd:items-center zd:gap-1.5">
302
314
  <Text className="zd:whitespace-nowrap zd:font-medium">{name}</Text>
303
315
  {logoUrl && (
304
- <img src={logoUrl} alt="" className="zd:size-4 zd:rounded-full" />
316
+ <img src={logoUrl} alt="" className="zd:size-4.5 zd:rounded-full" />
305
317
  )}
306
318
  </span>
307
319
  )
@@ -323,11 +335,8 @@ function TxLink({ label, href }: { label: string; href?: string | undefined }) {
323
335
  className="zd:inline-flex zd:items-center zd:gap-1 zd:text-body3 zd:text-greyScale zd:hover:text-solarOrange"
324
336
  >
325
337
  {label}
326
- <Icon
327
- name="export"
328
- className="zd:size-3 zd:text-solarOrange"
329
- aria-hidden
330
- />
338
+ {/* Inherits the link's ink color (Figma 20002:38055) — no orange. */}
339
+ <Icon name="export" className="zd:size-3" aria-hidden />
331
340
  </a>
332
341
  )
333
342
  }
package/src/types.ts CHANGED
@@ -27,13 +27,13 @@ export type SourceToken = {
27
27
 
28
28
  export type SmartRoutingAddressConfig = {
29
29
  /**
30
- * ZeroDev project id; when non-empty it is appended to the server URL
31
- * for every request
30
+ * ZeroDev project id, sent with every address-creation request (the SDK
31
+ * appends it to the server URL)
32
32
  */
33
33
  projectId?: string
34
34
  /** Chain id where funds settle */
35
35
  targetChainId: number
36
- /** Smart routing address version, defaults to the latest stable */
36
+ /** Smart routing address version, defaults to the latest supported */
37
37
  version?: SmartRoutingAddressVersion
38
38
  /**
39
39
  * Destination actions per token type. When omitted, funds are simply
@@ -44,10 +44,7 @@ export type SmartRoutingAddressConfig = {
44
44
  * Max slippage in basis points (50 = 0.5%).
45
45
  */
46
46
  slippage: number
47
- /**
48
- * Override the smart routing address server root URL; the projectId is
49
- * appended to it
50
- */
47
+ /** Override the smart routing address server root URL */
51
48
  baseUrl?: string
52
49
  }
53
50
 
@@ -1,13 +1,12 @@
1
1
  import type {
2
- CreateSmartRoutingAddressParams,
2
+ CALL,
3
3
  SmartRoutingAddressVersion,
4
4
  TOKEN_TYPE,
5
5
  } from '@zerodev/smart-routing-address'
6
6
  import {
7
7
  createCall,
8
8
  FLEX,
9
- SMART_ROUTING_ADDRESS_SERVER_URL,
10
- SMART_ROUTING_ADDRESS_V0_2_1,
9
+ SMART_ROUTING_ADDRESS_V1_0_0,
11
10
  SUPPORTED_TOKENS,
12
11
  } from '@zerodev/smart-routing-address'
13
12
  import { type Address, type Chain, erc20Abi } from 'viem'
@@ -23,21 +22,7 @@ import { tokenAddressMatches } from './fees'
23
22
  export function resolveVersion(
24
23
  config: SmartRoutingAddressConfig,
25
24
  ): SmartRoutingAddressVersion {
26
- return config.version ?? SMART_ROUTING_ADDRESS_V0_2_1
27
- }
28
-
29
- /**
30
- * Server URL for all smart routing address requests: the server root
31
- * (default or `config.baseUrl`) with the ZeroDev project id appended.
32
- * Without a project id the URL is only overridden when `config.baseUrl`
33
- * is set; undefined leaves the SDK default in place.
34
- */
35
- export function resolveBaseUrl(
36
- config: SmartRoutingAddressConfig,
37
- ): string | undefined {
38
- if (!config.projectId) return config.baseUrl
39
- const root = config.baseUrl ?? SMART_ROUTING_ADDRESS_SERVER_URL
40
- return `${root.replace(/\/+$/, '')}/${config.projectId}`
25
+ return config.version ?? SMART_ROUTING_ADDRESS_V1_0_0
41
26
  }
42
27
 
43
28
  export function resolveDashboardUrl(address?: string): string {
@@ -102,11 +87,16 @@ function uniqueTokenTypes(sources: SourceToken[]): TOKEN_TYPE[] {
102
87
  * Destination actions for every resolved source token type: ERC-20 deposits
103
88
  * are transferred to the recipient and native deposits are forwarded as
104
89
  * value. FLEX placeholders are resolved by the server per deposit.
90
+ *
91
+ * v0.x managers additionally run `fallBack` when `action` reverts, so legacy
92
+ * versions get the same calls as the fallback; v1 managers have no fallback
93
+ * and reject the field.
105
94
  */
106
95
  export function resolveActions(
107
96
  config: SmartRoutingAddressConfig,
108
97
  recipient: Address,
109
- ): CreateSmartRoutingAddressParams['actions'] {
98
+ version: SmartRoutingAddressVersion,
99
+ ): { [key in TOKEN_TYPE]?: { action: CALL[]; fallBack?: CALL[] } } {
110
100
  const erc20Call = createCall({
111
101
  target: FLEX.TOKEN_ADDRESS,
112
102
  value: 0n,
@@ -118,15 +108,13 @@ export function resolveActions(
118
108
  target: recipient,
119
109
  value: FLEX.NATIVE_AMOUNT,
120
110
  })
111
+ const legacy = version !== SMART_ROUTING_ADDRESS_V1_0_0
121
112
 
122
113
  return Object.fromEntries(
123
- uniqueTokenTypes(resolveSourceTokens(config)).map((tokenType) => [
124
- tokenType,
125
- {
126
- action: tokenType === 'NATIVE' ? [nativeCall] : [erc20Call],
127
- fallBack: tokenType === 'NATIVE' ? [nativeCall] : [erc20Call],
128
- },
129
- ]),
114
+ uniqueTokenTypes(resolveSourceTokens(config)).map((tokenType) => {
115
+ const action = tokenType === 'NATIVE' ? [nativeCall] : [erc20Call]
116
+ return [tokenType, { action, ...(legacy && { fallBack: action }) }]
117
+ }),
130
118
  )
131
119
  }
132
120
 
@@ -1,28 +0,0 @@
1
- interface QrCodeProps {
2
- /** Data to encode. */
3
- value: string;
4
- /** Pixel size of the rendered SVG square. */
5
- size: number;
6
- /** Error correction level. Defaults to `'M'`. */
7
- errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H';
8
- /** Corner radius (in modules, scaled to px internally) for finder patterns. */
9
- eyeRadius?: number;
10
- }
11
- /**
12
- * Custom QR renderer that draws runs of horizontally-adjacent data modules as
13
- * a single rounded pill. Finder patterns stay as sharp concentric squares
14
- * (rounding them breaks the 1:1:3:1:1 corner ratio scanners use to locate the
15
- * code). Isolated data modules render as pill-ish rects — visually close to
16
- * circles at very small `PILL_PAD_RATIO`, still square-adjacent for scanners.
17
- *
18
- * Default `errorCorrectionLevel` is `'H'` (30% recovery) so the decorative
19
- * rounding + shrinkage has plenty of headroom before scans fail.
20
- *
21
- * Quiet zone (spec: ≥4 modules of white around the code) is expected to be
22
- * provided by the caller — e.g. via a white-background padded wrapper. This
23
- * component fills the given `size` with the QR data area itself so the
24
- * consumer controls the visual footprint.
25
- */
26
- export declare function QrCode({ value, size, errorCorrectionLevel, eyeRadius, }: QrCodeProps): import("react").JSX.Element;
27
- export {};
28
- //# sourceMappingURL=QrCode.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"QrCode.d.ts","sourceRoot":"","sources":["../../../../src/components/QrSheet/QrCode.tsx"],"names":[],"mappings":"AAEA,UAAU,WAAW;IACnB,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,6CAA6C;IAC7C,IAAI,EAAE,MAAM,CAAA;IACZ,iDAAiD;IACjD,oBAAoB,CAAC,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;IAC5C,+EAA+E;IAC/E,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAUD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,MAAM,CAAC,EACrB,KAAK,EACL,IAAI,EACJ,oBAA0B,EAC1B,SAAa,GACd,EAAE,WAAW,+BAyHb"}
@@ -1,163 +0,0 @@
1
- import { encode } from 'uqr'
2
-
3
- interface QrCodeProps {
4
- /** Data to encode. */
5
- value: string
6
- /** Pixel size of the rendered SVG square. */
7
- size: number
8
- /** Error correction level. Defaults to `'M'`. */
9
- errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H'
10
- /** Corner radius (in modules, scaled to px internally) for finder patterns. */
11
- eyeRadius?: number
12
- }
13
-
14
- const FINDER_SIZE = 7 // every QR has three 7×7 finder patterns
15
- const MODULE_COLOR = '#000'
16
- const BG_COLOR = '#fff'
17
- /** Vertical shrink applied to data-module pills so rows read as distinct
18
- * without introducing white slivers wide enough to look like transitions
19
- * to a scanner. Kept small (5%) so total black area stays close to spec. */
20
- const PILL_PAD_RATIO = 0.05
21
-
22
- /**
23
- * Custom QR renderer that draws runs of horizontally-adjacent data modules as
24
- * a single rounded pill. Finder patterns stay as sharp concentric squares
25
- * (rounding them breaks the 1:1:3:1:1 corner ratio scanners use to locate the
26
- * code). Isolated data modules render as pill-ish rects — visually close to
27
- * circles at very small `PILL_PAD_RATIO`, still square-adjacent for scanners.
28
- *
29
- * Default `errorCorrectionLevel` is `'H'` (30% recovery) so the decorative
30
- * rounding + shrinkage has plenty of headroom before scans fail.
31
- *
32
- * Quiet zone (spec: ≥4 modules of white around the code) is expected to be
33
- * provided by the caller — e.g. via a white-background padded wrapper. This
34
- * component fills the given `size` with the QR data area itself so the
35
- * consumer controls the visual footprint.
36
- */
37
- export function QrCode({
38
- value,
39
- size,
40
- errorCorrectionLevel = 'H',
41
- eyeRadius = 0,
42
- }: QrCodeProps) {
43
- // `uqr` returns a 2D boolean matrix (`data[row][col]`) and the module count
44
- // per side. Same information as `qrcode`, just shaped differently — the
45
- // pill/finder rendering below is unchanged.
46
- const qr = encode(value, { ecc: errorCorrectionLevel, border: 0 })
47
- const matrix = qr.data
48
- const moduleCount = qr.size
49
- const cellSize = size / moduleCount
50
- const eyeRadiusPx = eyeRadius * cellSize
51
-
52
- const finders = [
53
- { row: 0, col: 0 },
54
- { row: 0, col: moduleCount - FINDER_SIZE },
55
- { row: moduleCount - FINDER_SIZE, col: 0 },
56
- ]
57
-
58
- const inFinder = (row: number, col: number) =>
59
- finders.some(
60
- (f) =>
61
- row >= f.row &&
62
- row < f.row + FINDER_SIZE &&
63
- col >= f.col &&
64
- col < f.col + FINDER_SIZE,
65
- )
66
-
67
- // Collect maximal horizontal runs of `on` modules, skipping finder regions.
68
- const runs: { row: number; col: number; length: number }[] = []
69
- for (let row = 0; row < moduleCount; row++) {
70
- let col = 0
71
- while (col < moduleCount) {
72
- if (inFinder(row, col)) {
73
- col++
74
- continue
75
- }
76
- if (matrix[row]?.[col]) {
77
- let length = 1
78
- while (
79
- col + length < moduleCount &&
80
- !inFinder(row, col + length) &&
81
- matrix[row]?.[col + length]
82
- ) {
83
- length++
84
- }
85
- runs.push({ row, col, length })
86
- col += length
87
- } else {
88
- col++
89
- }
90
- }
91
- }
92
-
93
- return (
94
- <svg
95
- width={size}
96
- height={size}
97
- viewBox={`0 0 ${size} ${size}`}
98
- xmlns="http://www.w3.org/2000/svg"
99
- role="img"
100
- aria-label={`QR code for ${value}`}
101
- >
102
- <rect width={size} height={size} fill={BG_COLOR} />
103
- {runs.map(({ row, col, length }) => {
104
- // Shrink each pill vertically so rows read as distinct without
105
- // creating scanner-confusing white gaps mid-run. Isolated modules
106
- // (length === 1) also shrink horizontally so they render close to a
107
- // circle instead of an oval.
108
- const pad = cellSize * PILL_PAD_RATIO
109
- const pillHeight = cellSize - 2 * pad
110
- const isSingle = length === 1
111
- return (
112
- <rect
113
- key={`r${row}-${col}`}
114
- x={col * cellSize + (isSingle ? pad : 0)}
115
- y={row * cellSize + pad}
116
- width={length * cellSize - (isSingle ? 2 * pad : 0)}
117
- height={pillHeight}
118
- rx={pillHeight / 2}
119
- ry={pillHeight / 2}
120
- fill={MODULE_COLOR}
121
- />
122
- )
123
- })}
124
- {finders.map(({ row, col }) => {
125
- const x = col * cellSize
126
- const y = row * cellSize
127
- const outerSize = FINDER_SIZE * cellSize
128
- const innerSize = 3 * cellSize
129
- return (
130
- <g key={`f${row}-${col}`}>
131
- <rect
132
- x={x}
133
- y={y}
134
- width={outerSize}
135
- height={outerSize}
136
- rx={eyeRadiusPx}
137
- ry={eyeRadiusPx}
138
- fill={MODULE_COLOR}
139
- />
140
- <rect
141
- x={x + cellSize}
142
- y={y + cellSize}
143
- width={outerSize - 2 * cellSize}
144
- height={outerSize - 2 * cellSize}
145
- rx={Math.max(eyeRadiusPx - cellSize, 0)}
146
- ry={Math.max(eyeRadiusPx - cellSize, 0)}
147
- fill={BG_COLOR}
148
- />
149
- <rect
150
- x={x + 2 * cellSize}
151
- y={y + 2 * cellSize}
152
- width={innerSize}
153
- height={innerSize}
154
- rx={Math.max(eyeRadiusPx - 2 * cellSize, 0)}
155
- ry={Math.max(eyeRadiusPx - 2 * cellSize, 0)}
156
- fill={MODULE_COLOR}
157
- />
158
- </g>
159
- )
160
- })}
161
- </svg>
162
- )
163
- }