@@ -109,13 +114,13 @@ function TemplateCard({
- Recipient: {invoiceData.recipient.name || 'N/A'}
+ Client: {clientName}
- Currency: {invoiceData.currencySymbol}
+ Currency: {currency}
- Items: {invoiceData.lineItems.length}
+ Items: {itemsCount}
diff --git a/src/features/invoice-history/lib/search.ts b/src/features/invoice-history/lib/search.ts
index 6bfcab8a..d3a90384 100644
--- a/src/features/invoice-history/lib/search.ts
+++ b/src/features/invoice-history/lib/search.ts
@@ -4,12 +4,12 @@
* Client-side search functionality for history entries.
*/
-import type { CreationHistoryEntry } from '@/entities/invoice'
+import { formatInvoiceTotal, type CreationHistoryEntry } from '@/entities/invoice'
/**
* Search history entries by query string
*
- * Searches across: invoiceId, recipientName, totalAmount
+ * Searches across: invoiceId, client name, total amount
*
* @param history - Array of history entries
* @param query - Search query (case-insensitive)
@@ -26,7 +26,11 @@ export function searchHistory(
const normalizedQuery = query.toLowerCase().trim()
return history.filter((entry) => {
- const searchableText = [entry.invoiceId, entry.recipientName, entry.totalAmount]
+ const searchableText = [
+ entry.invoice.invoiceId,
+ entry.invoice.client?.name ?? '',
+ formatInvoiceTotal(entry.invoice),
+ ]
.join(' ')
.toLowerCase()
@@ -103,14 +107,15 @@ export function sortHistory(
case 'date':
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
break
- case 'amount':
- // Extract numeric value from totalAmount string (e.g., "1250.50 USDC" -> 1250.50)
- const amountA = parseFloat(a.totalAmount.split(' ')[0] || '0')
- const amountB = parseFloat(b.totalAmount.split(' ')[0] || '0')
+ case 'amount': {
+ // Extract numeric value from formatted total
+ const amountA = parseFloat(formatInvoiceTotal(a.invoice).split(' ')[0] || '0')
+ const amountB = parseFloat(formatInvoiceTotal(b.invoice).split(' ')[0] || '0')
comparison = amountA - amountB
break
+ }
case 'invoiceId':
- comparison = a.invoiceId.localeCompare(b.invoiceId)
+ comparison = a.invoice.invoiceId.localeCompare(b.invoice.invoiceId)
break
}
diff --git a/src/features/invoice-history/ui/HistoryList.tsx b/src/features/invoice-history/ui/HistoryList.tsx
index ac643ac1..db10c33d 100644
--- a/src/features/invoice-history/ui/HistoryList.tsx
+++ b/src/features/invoice-history/ui/HistoryList.tsx
@@ -9,7 +9,7 @@
import { useState } from 'react'
import { useCreatorStore } from '@/entities/creator'
-import type { CreationHistoryEntry } from '@/entities/invoice'
+import { formatInvoiceTotal, type CreationHistoryEntry } from '@/entities/invoice'
interface HistoryListProps {
/** Optional CSS class name */
@@ -125,18 +125,20 @@ function HistoryEntryCard({
{/* Left: Invoice Info */}
-
{entry.invoiceId}
+
+ {entry.invoice.invoiceId}
+
{entry.txHash && (
Paid
)}
-
{entry.recipientName}
+
{entry.invoice.client?.name ?? 'Unknown'}
{formattedDate}
•
- {entry.totalAmount}
+ {formatInvoiceTotal(entry.invoice)}
diff --git a/src/shared/lib/binary-codec/base62.ts b/src/shared/lib/binary-codec/base62.ts
new file mode 100644
index 00000000..414ad946
--- /dev/null
+++ b/src/shared/lib/binary-codec/base62.ts
@@ -0,0 +1,75 @@
+/**
+ * Base62 Encoding/Decoding
+ * Alphabet: 0-9a-zA-Z (62 chars, URL-safe without special chars)
+ * More compact than Base64 for URLs
+ */
+
+const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
+
+/**
+ * Encodes a Uint8Array to Base62 string
+ */
+export function encodeBase62(bytes: Uint8Array): string {
+ if (bytes.length === 0) return ''
+
+ // Convert byte array to BigInt
+ let num = BigInt(0)
+ for (let i = 0; i < bytes.length; i++) {
+ num = num * BigInt(256) + BigInt(bytes[i]!)
+ }
+
+ // Convert to base62
+ if (num === BigInt(0)) return ALPHABET[0]!
+
+ let result = ''
+ while (num > BigInt(0)) {
+ const digit = ALPHABET[Number(num % BigInt(62))]
+ result = (digit ?? '') + result
+ num = num / BigInt(62)
+ }
+
+ // Preserve leading zeros
+ for (let i = 0; i < bytes.length && bytes[i] === 0; i++) {
+ result = ALPHABET[0]! + result
+ }
+
+ return result
+}
+
+/**
+ * Decodes a Base62 string to Uint8Array
+ */
+export function decodeBase62(str: string): Uint8Array {
+ if (str.length === 0) return new Uint8Array(0)
+
+ // Count leading zeros
+ let leadingZeros = 0
+ for (let i = 0; i < str.length && str[i] === ALPHABET[0]; i++) {
+ leadingZeros++
+ }
+
+ // Convert from base62 to BigInt
+ let num = BigInt(0)
+ for (let i = 0; i < str.length; i++) {
+ const char = str[i]
+ const digit = ALPHABET.indexOf(char!)
+ if (digit === -1) {
+ throw new Error(`Invalid Base62 character: ${char}`)
+ }
+ num = num * BigInt(62) + BigInt(digit)
+ }
+
+ // Convert to bytes
+ const bytes: number[] = []
+ while (num > BigInt(0)) {
+ bytes.unshift(Number(num % BigInt(256)))
+ num = num / BigInt(256)
+ }
+
+ // Add leading zeros
+ for (let i = 0; i < leadingZeros; i++) {
+ bytes.unshift(0)
+ }
+
+ return new Uint8Array(bytes)
+}
diff --git a/src/shared/lib/binary-codec/decoder-v3.ts b/src/shared/lib/binary-codec/decoder-v3.ts
new file mode 100644
index 00000000..d5aa740e
--- /dev/null
+++ b/src/shared/lib/binary-codec/decoder-v3.ts
@@ -0,0 +1,232 @@
+/**
+ * Binary Codec V3 - Hybrid Strategy Decoder (Fixed)
+ *
+ * Decodes invoices encoded with V3 hybrid compression strategy.
+ * Uses pako.inflate to decompress raw bytes (NO double decoding).
+ */
+
+import { Invoice } from '@/entities/invoice/model/schema'
+import { bytesToAddress, readVarInt } from './utils'
+import { decodeBase62 } from './base62'
+import { CURRENCY_DICT_REVERSE, TOKEN_DICT_REVERSE } from './dictionary'
+import pako from 'pako'
+
+/**
+ * Bit flags for optional fields (matches encoder)
+ */
+enum OptionalFields {
+ HAS_NOTES = 1 << 0,
+ HAS_TOKEN = 1 << 1,
+ HAS_SENDER_EMAIL = 1 << 2,
+ HAS_SENDER_ADDRESS = 1 << 3,
+ HAS_SENDER_PHONE = 1 << 4,
+ HAS_CLIENT_WALLET = 1 << 5,
+ HAS_CLIENT_EMAIL = 1 << 6,
+ HAS_CLIENT_ADDRESS = 1 << 7,
+ HAS_CLIENT_PHONE = 1 << 8,
+ HAS_TAX = 1 << 9,
+ HAS_DISCOUNT = 1 << 10,
+ TEXT_COMPRESSED = 1 << 11,
+}
+
+/**
+ * Decodes invoice from hybrid compressed format
+ * Prefix: 'H' (Hybrid)
+ */
+export function decodeBinaryV3(encoded: string): Invoice {
+ // 1. Check prefix
+ if (!encoded.startsWith('H')) {
+ throw new Error('Invalid V3 encoding: must start with H')
+ }
+
+ // 2. Remove prefix and decode from Base62
+ const base62Data = encoded.slice(1)
+ const bytes = decodeBase62(base62Data)
+
+ let offset = 0
+
+ // 3. Version
+ const version = bytes[offset++]
+ if (version !== 3) {
+ throw new Error(`Unsupported version: ${version}`)
+ }
+
+ // 4. Read flags (2 bytes)
+ const flagsHigh = bytes[offset++] ?? 0
+ const flagsLow = bytes[offset++] ?? 0
+ const flags = (flagsHigh << 8) | flagsLow
+
+ // 5. Invoice ID moved to text fields (user-defined string, not UUID)
+
+ // 6. Issue timestamp (4 bytes)
+ const issuedAt =
+ ((bytes[offset++] ?? 0) << 24) |
+ ((bytes[offset++] ?? 0) << 16) |
+ ((bytes[offset++] ?? 0) << 8) |
+ (bytes[offset++] ?? 0)
+
+ // 7. Delta for due date (varint)
+ const deltaResult = readVarInt(bytes, offset)
+ const delta = deltaResult.value
+ offset += deltaResult.bytesRead
+ const dueAt = issuedAt + delta
+
+ // 8. Network ID (varint)
+ const netResult = readVarInt(bytes, offset)
+ const networkId = netResult.value
+ offset += netResult.bytesRead
+
+ // 9. Decimals (varint)
+ const decResult = readVarInt(bytes, offset)
+ const decimals = decResult.value
+ offset += decResult.bytesRead
+
+ // 10. Token address (optional, with dictionary support)
+ let tokenAddress: string | undefined
+ if (flags & OptionalFields.HAS_TOKEN) {
+ const tokenFlag = bytes[offset++]
+ if (tokenFlag === 0) {
+ // Dictionary token
+ const tokenCode = bytes[offset++]
+ tokenAddress = TOKEN_DICT_REVERSE[tokenCode ?? 0]
+ } else {
+ // Custom token
+ const tokenBytes = bytes.slice(offset, offset + 20)
+ tokenAddress = bytesToAddress(tokenBytes)
+ offset += 20
+ }
+ }
+
+ // 11. From wallet address (20 bytes)
+ const fromAddressBytes = bytes.slice(offset, offset + 20)
+ const fromWalletAddress = bytesToAddress(fromAddressBytes)
+ offset += 20
+
+ // 12. Client wallet address (optional, 20 bytes)
+ let clientWalletAddress: string | undefined
+ if (flags & OptionalFields.HAS_CLIENT_WALLET) {
+ const clientAddressBytes = bytes.slice(offset, offset + 20)
+ clientWalletAddress = bytesToAddress(clientAddressBytes)
+ offset += 20
+ }
+
+ // 13. Line items count (varint)
+ const itemCountResult = readVarInt(bytes, offset)
+ const itemCount = itemCountResult.value
+ offset += itemCountResult.bytesRead
+
+ // 14. Read text data length (varint)
+ const textLengthResult = readVarInt(bytes, offset)
+ const textLength = textLengthResult.value
+ offset += textLengthResult.bytesRead
+
+ // 15. Read text data bytes
+ const textDataBytes = bytes.slice(offset, offset + textLength)
+ offset += textLength
+
+ // 16. Decompress text if needed
+ let rawTextBytes: Uint8Array
+ if (flags & OptionalFields.TEXT_COMPRESSED) {
+ try {
+ // Key improvement: pako.inflate expects raw Uint8Array and returns raw Uint8Array
+ rawTextBytes = pako.inflate(textDataBytes)
+ } catch (error) {
+ throw new Error('Failed to decompress text data: ' + (error as Error).message)
+ }
+ } else {
+ rawTextBytes = textDataBytes
+ }
+
+ // 17. Decode text from bytes
+ const textDecoder = new TextDecoder()
+ const textData = textDecoder.decode(rawTextBytes)
+
+ // 18. Parse text data (split by null separator)
+ const textParts = textData.split('\x00')
+ let textIdx = 0
+
+ // Invoice ID (user-defined string, first in text fields)
+ const invoiceId = textParts[textIdx++] ?? ''
+
+ // Currency (with dictionary support)
+ const currencyMarker = textParts[textIdx++]
+ let currency: string
+ if (currencyMarker === '\x01') {
+ // Dictionary
+ const currCode = textParts[textIdx++]?.charCodeAt(0)
+ currency = CURRENCY_DICT_REVERSE[currCode ?? 0] ?? 'UNKNOWN'
+ } else {
+ // String
+ currency = textParts[textIdx++] ?? 'UNKNOWN'
+ }
+
+ // Notes (optional)
+ let notes: string | undefined
+ if (flags & OptionalFields.HAS_NOTES) {
+ notes = textParts[textIdx++]
+ }
+
+ // From fields
+ const fromName = textParts[textIdx++] ?? ''
+ const fromEmail = flags & OptionalFields.HAS_SENDER_EMAIL ? textParts[textIdx++] : undefined
+ const fromPhysicalAddress =
+ flags & OptionalFields.HAS_SENDER_ADDRESS ? textParts[textIdx++] : undefined
+ const fromPhone = flags & OptionalFields.HAS_SENDER_PHONE ? textParts[textIdx++] : undefined
+
+ // Client fields
+ const clientName = textParts[textIdx++] ?? ''
+ const clientEmail = flags & OptionalFields.HAS_CLIENT_EMAIL ? textParts[textIdx++] : undefined
+ const clientPhysicalAddress =
+ flags & OptionalFields.HAS_CLIENT_ADDRESS ? textParts[textIdx++] : undefined
+ const clientPhone = flags & OptionalFields.HAS_CLIENT_PHONE ? textParts[textIdx++] : undefined
+
+ // Tax and Discount (optional)
+ const tax = flags & OptionalFields.HAS_TAX ? textParts[textIdx++] : undefined
+ const discount = flags & OptionalFields.HAS_DISCOUNT ? textParts[textIdx++] : undefined
+
+ // Line items (all fields from text)
+ const items = []
+ for (let i = 0; i < itemCount; i++) {
+ const description = textParts[textIdx++] ?? ''
+ const qtyStr = textParts[textIdx++] ?? '0'
+ const rate = textParts[textIdx++] ?? '0'
+
+ items.push({
+ description,
+ quantity: isNaN(Number(qtyStr)) ? qtyStr : Number(qtyStr),
+ rate,
+ })
+ }
+
+ // 19. Construct invoice
+ const invoice: Invoice = {
+ version: 2,
+ invoiceId,
+ issuedAt,
+ dueAt,
+ notes,
+ networkId,
+ currency,
+ tokenAddress,
+ decimals,
+ from: {
+ name: fromName,
+ walletAddress: fromWalletAddress,
+ email: fromEmail,
+ physicalAddress: fromPhysicalAddress,
+ phone: fromPhone,
+ },
+ client: {
+ name: clientName,
+ walletAddress: clientWalletAddress,
+ email: clientEmail,
+ physicalAddress: clientPhysicalAddress,
+ phone: clientPhone,
+ },
+ items,
+ tax,
+ discount,
+ }
+
+ return invoice
+}
diff --git a/src/shared/lib/binary-codec/dictionary.ts b/src/shared/lib/binary-codec/dictionary.ts
new file mode 100644
index 00000000..e74bb7a2
--- /dev/null
+++ b/src/shared/lib/binary-codec/dictionary.ts
@@ -0,0 +1,75 @@
+/**
+ * Dictionary Compression
+ *
+ * Pre-defined dictionaries for common strings to save bytes.
+ * Each common value is assigned a 1-byte code (0-255).
+ */
+
+/**
+ * Common currency symbols
+ */
+export const CURRENCY_DICT: Record
= {
+ 'USDC': 1,
+ 'USDT': 2,
+ 'DAI': 3,
+ 'ETH': 4,
+ 'WETH': 5,
+ 'MATIC': 6,
+ 'ARB': 7,
+ 'OP': 8,
+ 'AVAX': 9,
+ 'BNB': 10,
+ 'BUSD': 11,
+ 'FRAX': 12,
+ 'LUSD': 13,
+ 'sUSD': 14,
+ 'TUSD': 15,
+};
+
+export const CURRENCY_DICT_REVERSE: Record = Object.fromEntries(
+ Object.entries(CURRENCY_DICT).map(([k, v]) => [v, k])
+);
+
+/**
+ * Common token addresses (ERC-20)
+ */
+export const TOKEN_DICT: Record = {
+ // Ethereum Mainnet
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 1, // USDC
+ '0xdac17f958d2ee523a2206206994597c13d831ec7': 2, // USDT
+ '0x6b175474e89094c44da98b954eedeac495271d0f': 3, // DAI
+ '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': 4, // WETH
+ // Polygon
+ '0x2791bca1f2de4661ed88a30c99a7a9449aa84174': 5, // USDC (Polygon)
+ '0xc2132d05d31c914a87c6611c10748aeb04b58e8f': 6, // USDT (Polygon)
+ '0x8f3cf7ad23cd3cadbd9735aff958023239c6a063': 7, // DAI (Polygon)
+ // Arbitrum
+ '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8': 8, // USDC (Arbitrum)
+ '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9': 9, // USDT (Arbitrum)
+ '0xda10009cbd5d07dd0cecc66161fc93d7c9000da1': 10, // DAI (Arbitrum)
+};
+
+export const TOKEN_DICT_REVERSE: Record = Object.fromEntries(
+ Object.entries(TOKEN_DICT).map(([k, v]) => [v, k])
+);
+
+/**
+ * Encodes a string using dictionary if available, otherwise returns null
+ */
+export function encodeDictString(str: string, dict: Record): number | null {
+ // Case-insensitive lookup
+ const lower = str.toLowerCase();
+ for (const [key, value] of Object.entries(dict)) {
+ if (key.toLowerCase() === lower) {
+ return value;
+ }
+ }
+ return null;
+}
+
+/**
+ * Decodes a dictionary code back to string
+ */
+export function decodeDictString(code: number, dict: Record): string | null {
+ return dict[code] || null;
+}
diff --git a/src/shared/lib/binary-codec/encoder-v3.ts b/src/shared/lib/binary-codec/encoder-v3.ts
new file mode 100644
index 00000000..aacf50d2
--- /dev/null
+++ b/src/shared/lib/binary-codec/encoder-v3.ts
@@ -0,0 +1,214 @@
+/**
+ * Binary Codec V3 - Hybrid Strategy (Fixed)
+ *
+ * Combines V2 optimizations (bit-packing, delta encoding, dictionary)
+ * with selective Deflate compression for text fields only when beneficial.
+ *
+ * V2 optimizations:
+ * - Bit-packing for optional fields (2 bytes for all flags)
+ * - Dictionary compression for currencies and token addresses
+ * - Delta encoding for due date
+ *
+ * V3 addition:
+ * - Selective Deflate compression: only compress text fields if beneficial
+ * - Uses pako.deflate() which returns raw bytes (Uint8Array) - NO double encoding
+ * - Threshold: 100 bytes (deflate adds ~10 byte header)
+ */
+
+import { Invoice } from '@/entities/invoice/model/schema'
+import { addressToBytes, writeVarInt } from './utils'
+import { encodeBase62 } from './base62'
+import { CURRENCY_DICT, TOKEN_DICT } from './dictionary'
+import pako from 'pako'
+
+/**
+ * Bit flags for optional fields (2 bytes = 16 flags)
+ */
+enum OptionalFields {
+ HAS_NOTES = 1 << 0,
+ HAS_TOKEN = 1 << 1,
+ HAS_SENDER_EMAIL = 1 << 2,
+ HAS_SENDER_ADDRESS = 1 << 3,
+ HAS_SENDER_PHONE = 1 << 4,
+ HAS_CLIENT_WALLET = 1 << 5,
+ HAS_CLIENT_EMAIL = 1 << 6,
+ HAS_CLIENT_ADDRESS = 1 << 7,
+ HAS_CLIENT_PHONE = 1 << 8,
+ HAS_TAX = 1 << 9,
+ HAS_DISCOUNT = 1 << 10,
+ TEXT_COMPRESSED = 1 << 11, // If set, text fields are Deflate-compressed
+ // Bits 12-15 reserved for future use
+}
+
+/**
+ * Encodes invoice using hybrid compression strategy
+ * Prefix: 'H' (Hybrid)
+ */
+export function encodeBinaryV3(invoice: Invoice): string {
+ const buffer: number[] = []
+
+ // 1. Version (1 byte) - Version 3
+ buffer.push(3)
+
+ // 2. Compute bit flags for optional fields
+ let flags = 0
+ if (invoice.notes) flags |= OptionalFields.HAS_NOTES
+ if (invoice.tokenAddress) flags |= OptionalFields.HAS_TOKEN
+ if (invoice.from.email) flags |= OptionalFields.HAS_SENDER_EMAIL
+ if (invoice.from.physicalAddress) flags |= OptionalFields.HAS_SENDER_ADDRESS
+ if (invoice.from.phone) flags |= OptionalFields.HAS_SENDER_PHONE
+ if (invoice.client.walletAddress) flags |= OptionalFields.HAS_CLIENT_WALLET
+ if (invoice.client.email) flags |= OptionalFields.HAS_CLIENT_EMAIL
+ if (invoice.client.physicalAddress) flags |= OptionalFields.HAS_CLIENT_ADDRESS
+ if (invoice.client.phone) flags |= OptionalFields.HAS_CLIENT_PHONE
+ if (invoice.tax) flags |= OptionalFields.HAS_TAX
+ if (invoice.discount) flags |= OptionalFields.HAS_DISCOUNT
+
+ // We'll set TEXT_COMPRESSED bit later after collecting text
+ const flagsOffset = buffer.length
+ buffer.push(0, 0) // Placeholder for flags (2 bytes)
+
+ // 3. Invoice ID moved to text fields (user-defined string, not UUID)
+
+ // 4. Issue timestamp -> 4 bytes (UInt32)
+ const iss = invoice.issuedAt
+ buffer.push((iss >>> 24) & 0xff)
+ buffer.push((iss >>> 16) & 0xff)
+ buffer.push((iss >>> 8) & 0xff)
+ buffer.push(iss & 0xff)
+
+ // 5. Delta encoding for due date (saves 2-3 bytes typically)
+ const delta = invoice.dueAt - invoice.issuedAt
+ writeVarInt(buffer, delta)
+
+ // 6. Network ID -> varint
+ writeVarInt(buffer, invoice.networkId)
+
+ // 7. Decimals -> varint
+ writeVarInt(buffer, invoice.decimals)
+
+ // 8. Token address (optional) with dictionary support
+ if (invoice.tokenAddress) {
+ const tokenCode = TOKEN_DICT[invoice.tokenAddress.toLowerCase()]
+ if (tokenCode !== undefined) {
+ buffer.push(0) // Dictionary token
+ buffer.push(tokenCode)
+ } else {
+ buffer.push(1) // Custom token
+ const tokenBytes = addressToBytes(invoice.tokenAddress)
+ buffer.push(...Array.from(tokenBytes))
+ }
+ }
+
+ // 9. From wallet address -> 20 bytes
+ const fromAddressBytes = addressToBytes(invoice.from.walletAddress)
+ buffer.push(...Array.from(fromAddressBytes))
+
+ // 10. Client wallet address (optional) -> 20 bytes
+ if (invoice.client.walletAddress) {
+ const clientAddressBytes = addressToBytes(invoice.client.walletAddress)
+ buffer.push(...Array.from(clientAddressBytes))
+ }
+
+ // 11. Line items count -> varint
+ writeVarInt(buffer, invoice.items.length)
+
+ // Now collect all text fields to decide if compression is beneficial
+ const textParts: string[] = []
+
+ // Invoice ID (user-defined string, first in text fields)
+ textParts.push(invoice.invoiceId)
+
+ // Currency (with dictionary support)
+ const currencyCode = CURRENCY_DICT[invoice.currency]
+ if (currencyCode !== undefined) {
+ textParts.push('\x01') // Dict marker
+ textParts.push(String.fromCharCode(currencyCode))
+ } else {
+ textParts.push('\x02') // String marker
+ textParts.push(invoice.currency)
+ }
+
+ // Notes (optional)
+ if (invoice.notes) {
+ textParts.push(invoice.notes)
+ }
+
+ // From fields
+ textParts.push(invoice.from.name)
+ if (invoice.from.email) textParts.push(invoice.from.email)
+ if (invoice.from.physicalAddress) textParts.push(invoice.from.physicalAddress)
+ if (invoice.from.phone) textParts.push(invoice.from.phone)
+
+ // Client fields
+ textParts.push(invoice.client.name)
+ if (invoice.client.email) textParts.push(invoice.client.email)
+ if (invoice.client.physicalAddress) textParts.push(invoice.client.physicalAddress)
+ if (invoice.client.phone) textParts.push(invoice.client.phone)
+
+ // Tax and Discount (optional)
+ if (invoice.tax) textParts.push(invoice.tax)
+ if (invoice.discount) textParts.push(invoice.discount)
+
+ // Line items (all fields)
+ for (const item of invoice.items) {
+ textParts.push(item.description)
+ const qtyStr = typeof item.quantity === 'number' ? item.quantity.toString() : item.quantity
+ textParts.push(qtyStr)
+ textParts.push(item.rate)
+ }
+
+ // Join all text with null separator
+ const textData = textParts.join('\x00')
+
+ // Convert to bytes
+ const textEncoder = new TextEncoder()
+ const rawTextBytes = textEncoder.encode(textData)
+
+ // Decide if we should compress text
+ // Deflate adds ~10 byte header, so only compress if > 100 bytes
+ let finalTextBytes: Uint8Array
+ let isCompressed = false
+
+ if (rawTextBytes.length > 100) {
+ try {
+ // Key improvement: pako.deflate returns raw Uint8Array, NO string encoding
+ const compressedBytes = pako.deflate(rawTextBytes)
+
+ // Only use compression if it actually reduces size
+ if (compressedBytes.length < rawTextBytes.length) {
+ finalTextBytes = compressedBytes
+ isCompressed = true
+ } else {
+ // Compression didn't help
+ finalTextBytes = rawTextBytes
+ }
+ } catch {
+ // Fallback to raw if compression fails
+ finalTextBytes = rawTextBytes
+ }
+ } else {
+ // Text too short for compression overhead
+ finalTextBytes = rawTextBytes
+ }
+
+ // Update flags with compression decision
+ if (isCompressed) {
+ flags |= OptionalFields.TEXT_COMPRESSED
+ }
+
+ // Write flags back to buffer
+ buffer[flagsOffset] = (flags >> 8) & 0xff
+ buffer[flagsOffset + 1] = flags & 0xff
+
+ // Write text data length + raw bytes (NO additional encoding)
+ writeVarInt(buffer, finalTextBytes.length)
+ buffer.push(...Array.from(finalTextBytes))
+
+ // Convert to Uint8Array and encode to Base62
+ const bytes = new Uint8Array(buffer)
+ const encoded = encodeBase62(bytes)
+
+ // Add prefix
+ return 'H' + encoded
+}
diff --git a/src/shared/lib/binary-codec/index.ts b/src/shared/lib/binary-codec/index.ts
new file mode 100644
index 00000000..5ae57d85
--- /dev/null
+++ b/src/shared/lib/binary-codec/index.ts
@@ -0,0 +1,33 @@
+/**
+ * Binary Codec for Invoice Compression (V3)
+ *
+ * Custom binary packing algorithm that achieves superior compression
+ * compared to JSON + LZ-String by:
+ * - Converting UUIDs from 36 chars to 16 bytes
+ * - Converting addresses from 42 chars to 20 bytes
+ * - Using varint encoding for small numbers
+ * - Using Base62 encoding (URL-safe, more compact than Base64)
+ *
+ * V3 features:
+ * - Hybrid compression strategy
+ * - Binary format for structured data (UUID, addresses, numbers)
+ * - Selective LZ compression only for text fields when beneficial (> 50 chars)
+ * - Best of both worlds: compact binary + smart text compression
+ *
+ * Usage:
+ * ```ts
+ * import { encodeBinaryV3, decodeBinaryV3 } from '@/shared/lib/binary-codec';
+ *
+ * const invoice: Invoice = {...};
+ * const encoded = encodeBinaryV3(invoice);
+ * const decoded = decodeBinaryV3(encoded);
+ * ```
+ */
+
+// V3 exports (only supported version)
+export { encodeBinaryV3 } from './encoder-v3'
+export { decodeBinaryV3 } from './decoder-v3'
+
+// Utilities
+export { encodeBase62, decodeBase62 } from './base62'
+export * from './dictionary'
diff --git a/src/shared/lib/binary-codec/utils.ts b/src/shared/lib/binary-codec/utils.ts
new file mode 100644
index 00000000..af449472
--- /dev/null
+++ b/src/shared/lib/binary-codec/utils.ts
@@ -0,0 +1,199 @@
+/**
+ * Binary Codec Utilities
+ *
+ * Utilities for converting between different data types and binary representations.
+ */
+
+/**
+ * Parses UUID string (with or without dashes) to 16 bytes
+ * Example: "550e8400-e29b-41d4-a716-446655440000" -> Uint8Array(16)
+ */
+export function uuidToBytes(uuid: string): Uint8Array {
+ const hex = uuid.replace(/-/g, '');
+ if (hex.length !== 32) {
+ throw new Error(`Invalid UUID length: ${uuid}`);
+ }
+
+ const bytes = new Uint8Array(16);
+ for (let i = 0; i < 16; i++) {
+ bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
+ }
+ return bytes;
+}
+
+/**
+ * Converts 16 bytes back to UUID string with dashes
+ */
+export function bytesToUuid(bytes: Uint8Array): string {
+ if (bytes.length !== 16) {
+ throw new Error(`Invalid UUID bytes length: ${bytes.length}`);
+ }
+
+ const hex = Array.from(bytes)
+ .map(b => b.toString(16).padStart(2, '0'))
+ .join('');
+
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
+}
+
+/**
+ * Parses Ethereum address (0x...) to 20 bytes
+ */
+export function addressToBytes(address: string): Uint8Array {
+ const hex = address.startsWith('0x') ? address.slice(2) : address;
+ if (hex.length !== 40) {
+ throw new Error(`Invalid address length: ${address}`);
+ }
+
+ const bytes = new Uint8Array(20);
+ for (let i = 0; i < 20; i++) {
+ bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
+ }
+ return bytes;
+}
+
+/**
+ * Converts 20 bytes back to Ethereum address (0x...)
+ */
+export function bytesToAddress(bytes: Uint8Array): string {
+ if (bytes.length !== 20) {
+ throw new Error(`Invalid address bytes length: ${bytes.length}`);
+ }
+
+ return '0x' + Array.from(bytes)
+ .map(b => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+/**
+ * Writes a 32-bit unsigned integer to buffer (Big Endian)
+ */
+export function writeUInt32(buffer: number[], value: number): void {
+ buffer.push((value >>> 24) & 0xFF);
+ buffer.push((value >>> 16) & 0xFF);
+ buffer.push((value >>> 8) & 0xFF);
+ buffer.push(value & 0xFF);
+}
+
+/**
+ * Reads a 32-bit unsigned integer from buffer (Big Endian)
+ */
+export function readUInt32(bytes: Uint8Array, offset: number): { value: number; bytesRead: number } {
+ const value = ((bytes[offset] ?? 0) << 24) |
+ ((bytes[offset + 1] ?? 0) << 16) |
+ ((bytes[offset + 2] ?? 0) << 8) |
+ (bytes[offset + 3] ?? 0);
+ return { value: value >>> 0, bytesRead: 4 }; // >>> 0 converts to unsigned
+}
+
+/**
+ * Writes a varint (variable-length integer)
+ * Smaller numbers take fewer bytes (1-5 bytes for 32-bit values)
+ */
+export function writeVarInt(buffer: number[], value: number): void {
+ while (value > 0x7F) {
+ buffer.push((value & 0x7F) | 0x80);
+ value >>>= 7;
+ }
+ buffer.push(value & 0x7F);
+}
+
+/**
+ * Reads a varint from buffer
+ */
+export function readVarInt(bytes: Uint8Array, offset: number): { value: number; bytesRead: number } {
+ let value = 0;
+ let shift = 0;
+ let bytesRead = 0;
+
+ while (offset + bytesRead < bytes.length) {
+ const byte = bytes[offset + bytesRead] ?? 0;
+ bytesRead++;
+
+ value |= (byte & 0x7F) << shift;
+
+ if ((byte & 0x80) === 0) {
+ break;
+ }
+
+ shift += 7;
+ }
+
+ return { value, bytesRead };
+}
+
+/**
+ * Writes a length-prefixed string (varint length + UTF-8 bytes)
+ */
+export function writeString(buffer: number[], str: string): void {
+ const encoder = new TextEncoder();
+ const bytes = encoder.encode(str);
+ writeVarInt(buffer, bytes.length);
+ buffer.push(...Array.from(bytes));
+}
+
+/**
+ * Reads a length-prefixed string
+ */
+export function readString(bytes: Uint8Array, offset: number): { value: string; bytesRead: number } {
+ const { value: length, bytesRead: lengthBytes } = readVarInt(bytes, offset);
+ offset += lengthBytes;
+
+ const decoder = new TextDecoder();
+ const stringBytes = bytes.slice(offset, offset + length);
+ const value = decoder.decode(stringBytes);
+
+ return { value, bytesRead: lengthBytes + length };
+}
+
+/**
+ * Writes an optional string (1 byte flag + string if present)
+ */
+export function writeOptionalString(buffer: number[], str: string | undefined): void {
+ if (str === undefined) {
+ buffer.push(0);
+ } else {
+ buffer.push(1);
+ writeString(buffer, str);
+ }
+}
+
+/**
+ * Reads an optional string
+ */
+export function readOptionalString(bytes: Uint8Array, offset: number): { value: string | undefined; bytesRead: number } {
+ const flag = bytes[offset] ?? 0;
+ if (flag === 0) {
+ return { value: undefined, bytesRead: 1 };
+ }
+
+ const { value, bytesRead } = readString(bytes, offset + 1);
+ return { value, bytesRead: bytesRead + 1 };
+}
+
+/**
+ * Writes an optional address (1 byte flag + 20 bytes if present)
+ */
+export function writeOptionalAddress(buffer: number[], address: string | undefined): void {
+ if (address === undefined) {
+ buffer.push(0);
+ } else {
+ buffer.push(1);
+ const bytes = addressToBytes(address);
+ buffer.push(...Array.from(bytes));
+ }
+}
+
+/**
+ * Reads an optional address
+ */
+export function readOptionalAddress(bytes: Uint8Array, offset: number): { value: string | undefined; bytesRead: number } {
+ const flag = bytes[offset] ?? 0;
+ if (flag === 0) {
+ return { value: undefined, bytesRead: 1 };
+ }
+
+ const addressBytes = bytes.slice(offset + 1, offset + 21);
+ const value = bytesToAddress(addressBytes);
+ return { value, bytesRead: 21 };
+}
diff --git a/src/shared/lib/compression/index.ts b/src/shared/lib/compression/index.ts
deleted file mode 100644
index 8ae4181a..00000000
--- a/src/shared/lib/compression/index.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import * as lzString from 'lz-string'
-
-/**
- * Compresses a string using LZ-based compression optimized for URLs.
- * Uses lz-string's compressToEncodedURIComponent.
- */
-export const compress = (data: string): string => {
- return lzString.compressToEncodedURIComponent(data)
-}
-
-/**
- * Decompresses a string compressed with compress().
- * Returns null if decompression fails or input is invalid.
- */
-export const decompress = (compressed: string): string | null => {
- return lzString.decompressFromEncodedURIComponent(compressed)
-}
diff --git a/src/shared/lib/hooks/index.ts b/src/shared/lib/hooks/index.ts
index d22fe4b0..6dde8394 100644
--- a/src/shared/lib/hooks/index.ts
+++ b/src/shared/lib/hooks/index.ts
@@ -1 +1,2 @@
export { useHydrated } from './use-hydrated'
+export { useHashFragment } from './use-hash-fragment'
diff --git a/src/shared/lib/hooks/use-hash-fragment.ts b/src/shared/lib/hooks/use-hash-fragment.ts
new file mode 100644
index 00000000..eafa1ea1
--- /dev/null
+++ b/src/shared/lib/hooks/use-hash-fragment.ts
@@ -0,0 +1,52 @@
+import { useSyncExternalStore } from 'react'
+
+/**
+ * Subscribe to hash change events
+ */
+function subscribeToHash(callback: () => void): () => void {
+ window.addEventListener('hashchange', callback)
+ return () => window.removeEventListener('hashchange', callback)
+}
+
+/**
+ * Get current hash fragment without the leading '#'
+ */
+function getHashSnapshot(): string {
+ return window.location.hash.slice(1)
+}
+
+/**
+ * Server snapshot — hash is unavailable during SSR
+ */
+function getServerSnapshot(): string {
+ return ''
+}
+
+/**
+ * Hook to read URL hash fragment reactively.
+ * Hash fragments are never sent to the server (Privacy-First).
+ *
+ * Uses useSyncExternalStore for:
+ * - Optimal performance (no extra re-renders)
+ * - SSR compatibility (returns '' on server)
+ * - Automatic updates on hash change
+ *
+ * @returns Hash fragment string (without '#'), empty on server
+ *
+ * @example
+ * ```tsx
+ * const hash = useHashFragment()
+ *
+ * // Decode invoice from hash
+ * if (hash) {
+ * const invoice = decodeInvoice(hash)
+ * }
+ * ```
+ */
+export function useHashFragment(): string {
+ return useSyncExternalStore(
+ subscribeToHash,
+ getHashSnapshot,
+ getServerSnapshot
+ )
+}
diff --git a/src/shared/lib/test-utils/invoice-generator.ts b/src/shared/lib/test-utils/invoice-generator.ts
new file mode 100644
index 00000000..10b137d7
--- /dev/null
+++ b/src/shared/lib/test-utils/invoice-generator.ts
@@ -0,0 +1,188 @@
+/**
+ * Random Invoice Generator
+ *
+ * Generates random invoices for testing and demonstration purposes.
+ */
+
+import { Invoice } from '@/entities/invoice/model/schema'
+
+/**
+ * Generates a random UUID v4
+ */
+function generateUUID(): string {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0
+ const v = c === 'x' ? r : (r & 0x3) | 0x8
+ return v.toString(16)
+ })
+}
+
+/**
+ * Generates a random Ethereum address
+ */
+function generateAddress(): string {
+ const chars = '0123456789abcdef'
+ let address = '0x'
+ for (let i = 0; i < 40; i++) {
+ address += chars[Math.floor(Math.random() * chars.length)]
+ }
+ return address
+}
+
+/**
+ * Random item from array
+ */
+function randomItem(arr: T[]): T {
+ const item = arr[Math.floor(Math.random() * arr.length)]
+ if (item === undefined) {
+ throw new Error('Array is empty or invalid index')
+ }
+ return item
+}
+
+/**
+ * Random number between min and max
+ */
+function randomInt(min: number, max: number): number {
+ return Math.floor(Math.random() * (max - min + 1)) + min
+}
+
+/**
+ * Random boolean with given probability (default 50%)
+ */
+function randomBool(probability = 0.5): boolean {
+ return Math.random() < probability
+}
+
+/**
+ * Sample data for realistic invoices
+ */
+const SAMPLE_DATA = {
+ senderNames: [
+ 'Acme Corp',
+ 'TechStart Inc',
+ 'Digital Solutions LLC',
+ 'BlockChain Ventures',
+ 'Web3 Consulting',
+ 'Crypto Advisory Group',
+ ],
+ clientNames: [
+ 'GlobalTech Industries',
+ 'Innovation Labs',
+ 'Smart Contracts Co',
+ 'Decentralized Systems',
+ 'Future Finance',
+ 'Digital Assets Fund',
+ ],
+ emails: ['info@example.com', 'contact@company.io', 'hello@startup.xyz', 'admin@business.com'],
+ addresses: [
+ '123 Main Street\nSan Francisco, CA 94105\nUSA',
+ '456 Tech Avenue\nNew York, NY 10001\nUSA',
+ '789 Innovation Blvd\nAustin, TX 78701\nUSA',
+ ],
+ phones: ['+1-555-0100', '+1-555-0200', '+1-555-0300'],
+ itemDescriptions: [
+ 'Web Development Services',
+ 'Smart Contract Audit',
+ 'Blockchain Consulting',
+ 'UI/UX Design',
+ 'Technical Documentation',
+ 'System Integration',
+ 'DevOps Services',
+ 'Security Assessment',
+ ],
+ currencies: [
+ { symbol: 'USDC', decimals: 6, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' },
+ { symbol: 'USDT', decimals: 6, address: '0xdac17f958d2ee523a2206206994597c13d831ec7' },
+ { symbol: 'DAI', decimals: 18, address: '0x6b175474e89094c44da98b954eedeac495271d0f' },
+ { symbol: 'ETH', decimals: 18, address: undefined },
+ ],
+ chainIds: [
+ { id: 1, name: 'Ethereum' },
+ { id: 42161, name: 'Arbitrum' },
+ { id: 10, name: 'Optimism' },
+ { id: 137, name: 'Polygon' },
+ ],
+}
+
+/**
+ * Generates a random invoice with realistic data
+ */
+export function generateRandomInvoice(): Invoice {
+ const currency = randomItem(SAMPLE_DATA.currencies)
+ const chainId = randomItem(SAMPLE_DATA.chainIds)
+
+ // Generate timestamps
+ const now = Math.floor(Date.now() / 1000)
+ const iss = now - randomInt(0, 7 * 24 * 60 * 60) // Up to 7 days ago
+ const due = iss + randomInt(7, 60) * 24 * 60 * 60 // 7-60 days from issue
+
+ // Generate line items (1-5 items)
+ const itemCount = randomInt(1, 5)
+ const it = Array.from({ length: itemCount }, () => {
+ const qty = randomInt(1, 100)
+ const rate = (randomInt(10, 5000) * Math.pow(10, currency.decimals)).toString()
+
+ return {
+ description: randomItem(SAMPLE_DATA.itemDescriptions),
+ quantity: qty,
+ rate: rate,
+ }
+ })
+
+ // Optional fields (70% chance each)
+ const includeNotes = randomBool(0.7)
+ const includeTax = randomBool(0.7)
+ const includeDiscount = randomBool(0.3)
+
+ const includeSenderEmail = randomBool(0.8)
+ const includeSenderAddress = randomBool(0.6)
+ const includeSenderPhone = randomBool(0.6)
+
+ const includeClientWallet = randomBool(0.5)
+ const includeClientEmail = randomBool(0.7)
+ const includeClientAddress = randomBool(0.5)
+ const includeClientPhone = randomBool(0.4)
+
+ const invoice: Invoice = {
+ version: 2,
+ invoiceId: generateUUID(),
+ issuedAt: iss,
+ dueAt: due,
+ notes: includeNotes
+ ? 'Payment due within ' +
+ randomInt(7, 30) +
+ ' days. Please include invoice number in transaction memo.'
+ : undefined,
+ networkId: chainId.id,
+ currency: currency.symbol,
+ tokenAddress: currency.address,
+ decimals: currency.decimals,
+ from: {
+ name: randomItem(SAMPLE_DATA.senderNames),
+ walletAddress: generateAddress(),
+ email: includeSenderEmail ? randomItem(SAMPLE_DATA.emails) : undefined,
+ physicalAddress: includeSenderAddress ? randomItem(SAMPLE_DATA.addresses) : undefined,
+ phone: includeSenderPhone ? randomItem(SAMPLE_DATA.phones) : undefined,
+ },
+ client: {
+ name: randomItem(SAMPLE_DATA.clientNames),
+ walletAddress: includeClientWallet ? generateAddress() : undefined,
+ email: includeClientEmail ? randomItem(SAMPLE_DATA.emails) : undefined,
+ physicalAddress: includeClientAddress ? randomItem(SAMPLE_DATA.addresses) : undefined,
+ phone: includeClientPhone ? randomItem(SAMPLE_DATA.phones) : undefined,
+ },
+ items: it,
+ tax: includeTax ? randomInt(5, 25) + '%' : undefined,
+ discount: includeDiscount ? randomInt(5, 20) + '%' : undefined,
+ }
+
+ return invoice
+}
+
+/**
+ * Generates multiple random invoices
+ */
+export function generateRandomInvoices(count: number): Invoice[] {
+ return Array.from({ length: count }, () => generateRandomInvoice())
+}
diff --git a/src/shared/ui/icons/critical-icons.tsx b/src/shared/ui/icons/critical-icons.tsx
index 43778682..a478e8f4 100644
--- a/src/shared/ui/icons/critical-icons.tsx
+++ b/src/shared/ui/icons/critical-icons.tsx
@@ -92,3 +92,64 @@ export function GithubIcon({ size = 24, ...props }: IconProps) {
)
}
+
+/**
+ * Mail icon - Used in PartyInfo contact details
+ */
+export function MailIcon({ size = 24, ...props }: IconProps) {
+ return (
+
+ )
+}
+
+/**
+ * Phone icon - Used in PartyInfo contact details
+ */
+export function PhoneIcon({ size = 24, ...props }: IconProps) {
+ return (
+
+ )
+}
+
+/**
+ * MapPin icon - Used in PartyInfo physical address
+ */
+export function MapPinIcon({ size = 24, ...props }: IconProps) {
+ return (
+
+ )
+}
+
+/**
+ * Wallet icon - Used in PartyInfo wallet address
+ */
+export function WalletIcon({ size = 24, ...props }: IconProps) {
+ return (
+
+ )
+}
+
+/**
+ * Hash icon - Used in PartyInfo tax ID
+ */
+export function HashIcon({ size = 24, ...props }: IconProps) {
+ return (
+
+ )
+}
diff --git a/src/shared/ui/icons/index.ts b/src/shared/ui/icons/index.ts
index af327c8f..eb09492c 100644
--- a/src/shared/ui/icons/index.ts
+++ b/src/shared/ui/icons/index.ts
@@ -11,4 +11,9 @@ export {
ServerOffIcon,
GlobeIcon,
GithubIcon,
+ MailIcon,
+ PhoneIcon,
+ MapPinIcon,
+ WalletIcon,
+ HashIcon,
} from './critical-icons'
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts
index 556fbf4c..0ad63298 100644
--- a/src/shared/ui/index.ts
+++ b/src/shared/ui/index.ts
@@ -50,7 +50,18 @@ export { useReducedMotion } from './hooks/use-reduced-motion'
export type { NetworkTheme } from './constants/brand-tokens'
// Critical path inline SVG icons (above-fold optimization)
-export { ArrowRightIcon, LockIcon, ServerOffIcon, GlobeIcon, GithubIcon } from './icons'
+export {
+ ArrowRightIcon,
+ LockIcon,
+ ServerOffIcon,
+ GlobeIcon,
+ GithubIcon,
+ MailIcon,
+ PhoneIcon,
+ MapPinIcon,
+ WalletIcon,
+ HashIcon,
+} from './icons'
// Popover (existing)
export { Popover, PopoverTrigger, PopoverContent } from './popover'
diff --git a/src/widgets/invoice-paper/__tests__/InvoicePaper.preview.test.tsx b/src/widgets/invoice-paper/__tests__/InvoicePaper.preview.test.tsx
index 3c1d43fb..7346e968 100644
--- a/src/widgets/invoice-paper/__tests__/InvoicePaper.preview.test.tsx
+++ b/src/widgets/invoice-paper/__tests__/InvoicePaper.preview.test.tsx
@@ -1,25 +1,25 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { InvoicePaper } from '../ui/InvoicePaper'
-import type { InvoiceSchemaV1 } from '@/entities/invoice'
+import type { Invoice } from '@/entities/invoice'
describe('InvoicePaper Preview Mode', () => {
it('renders placeholder for empty items', () => {
const emptyData = {
- f: { n: 'Alice', a: '0x1' },
- c: { n: 'Bob', a: '0x2' },
- it: [],
- cur: 'USDC',
- } as unknown as InvoiceSchemaV1
+ from: { name: 'Alice', walletAddress: '0x1' },
+ client: { name: 'Bob', walletAddress: '0x2' },
+ items: [],
+ currency: 'USDC',
+ } as unknown as Invoice
render()
expect(screen.getByText(/No line items/i)).toBeDefined()
})
it('renders default values for missing data', () => {
const minimalData = {
- f: { n: 'Alice' },
- c: { n: 'Bob' },
- } as unknown as InvoiceSchemaV1
+ from: { name: 'Alice' },
+ client: { name: 'Bob' },
+ } as unknown as Invoice
render()
expect(screen.getByText(/Alice/i)).toBeDefined()
expect(screen.getByText(/Bob/i)).toBeDefined()
@@ -29,9 +29,9 @@ describe('InvoicePaper Preview Mode', () => {
it('handles optional notes', () => {
const minimalData = {
- f: { n: 'Alice' },
- c: { n: 'Bob' },
- } as unknown as InvoiceSchemaV1
+ from: { name: 'Alice' },
+ client: { name: 'Bob' },
+ } as unknown as Invoice
render()
expect(screen.getByText(/Thank you for your business/i)).toBeDefined()
})
diff --git a/src/widgets/invoice-paper/__tests__/InvoicePaper.print.test.tsx b/src/widgets/invoice-paper/__tests__/InvoicePaper.print.test.tsx
index 747c8903..e3f32f47 100644
--- a/src/widgets/invoice-paper/__tests__/InvoicePaper.print.test.tsx
+++ b/src/widgets/invoice-paper/__tests__/InvoicePaper.print.test.tsx
@@ -2,7 +2,7 @@ import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import React from 'react'
import { InvoicePaper } from '../ui/InvoicePaper'
-import type { InvoiceSchemaV1 } from '@/entities/invoice'
+import type { Invoice } from '@/entities/invoice'
describe('InvoicePaper Print Support', () => {
it('supports forwardRef for printing', () => {
@@ -16,7 +16,7 @@ describe('InvoicePaper Print Support', () => {
f: { n: 'S', a: '0x1' },
c: { n: 'C', a: '0x2' },
it: [],
- } as unknown as InvoiceSchemaV1
+ } as unknown as Invoice
render()
expect(ref.current).not.toBeNull()
@@ -32,7 +32,7 @@ describe('InvoicePaper Print Support', () => {
f: { n: 'S', a: '0x1' },
c: { n: 'C', a: '0x2' },
it: [],
- } as unknown as InvoiceSchemaV1
+ } as unknown as Invoice
const { container } = render()
const paper = container.firstChild as HTMLElement
diff --git a/src/widgets/invoice-paper/__tests__/InvoicePaper.test.tsx b/src/widgets/invoice-paper/__tests__/InvoicePaper.test.tsx
index e4570452..49b83ba8 100644
--- a/src/widgets/invoice-paper/__tests__/InvoicePaper.test.tsx
+++ b/src/widgets/invoice-paper/__tests__/InvoicePaper.test.tsx
@@ -1,18 +1,18 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { InvoicePaper } from '../ui/InvoicePaper'
-import { InvoiceSchemaV1 } from '@/entities/invoice'
+import { Invoice } from '@/entities/invoice'
describe('InvoicePaper Integration', () => {
- const mockData: Partial = {
- id: 'INV-2024-001',
- iss: 1735296000, // Dec 27
- due: 1735382400, // Dec 28
- f: { n: 'Alice', a: '0xSender' },
- c: { n: 'Bob', a: '0xRecipient' },
- it: [{ d: 'Web Design', q: 1, r: '1500' }],
- cur: 'USDC',
- net: 1,
+ const mockData: Partial = {
+ invoiceId: 'INV-2024-001',
+ issuedAt: 1735296000, // Dec 27
+ dueAt: 1735382400, // Dec 28
+ from: { name: 'Alice', walletAddress: '0xSender' },
+ client: { name: 'Bob', walletAddress: '0xRecipient' },
+ items: [{ description: 'Web Design', quantity: 1, rate: '1500' }],
+ currency: 'USDC',
+ networkId: 1,
}
it('renders all core sections', () => {
diff --git a/src/widgets/invoice-paper/lib/__tests__/calculate-totals.test.ts b/src/widgets/invoice-paper/lib/__tests__/calculate-totals.test.ts
index 6feb2199..98c87973 100644
--- a/src/widgets/invoice-paper/lib/__tests__/calculate-totals.test.ts
+++ b/src/widgets/invoice-paper/lib/__tests__/calculate-totals.test.ts
@@ -4,8 +4,8 @@ import { calculateTotals } from '../calculate-totals'
describe('calculateTotals', () => {
it('calculates basic subtotal correctly', () => {
const items = [
- { d: 'Item 1', q: 2, r: '100' },
- { d: 'Item 2', q: 1, r: '50' },
+ { description: 'Item 1', quantity: 2, rate: '100' },
+ { description: 'Item 2', quantity: 1, rate: '50' },
]
const result = calculateTotals(items)
expect(result.subtotal).toBe(250)
@@ -13,7 +13,7 @@ describe('calculateTotals', () => {
})
it('handles percentage tax correctly', () => {
- const items = [{ d: 'Item 1', q: 1, r: '100' }]
+ const items = [{ description: 'Item 1', quantity: 1, rate: '100' }]
const result = calculateTotals(items, { tax: '10%' })
expect(result.subtotal).toBe(100)
expect(result.taxAmount).toBe(10)
@@ -21,7 +21,7 @@ describe('calculateTotals', () => {
})
it('handles fixed tax correctly', () => {
- const items = [{ d: 'Item 1', q: 1, r: '100' }]
+ const items = [{ description: 'Item 1', quantity: 1, rate: '100' }]
const result = calculateTotals(items, { tax: '5' })
expect(result.subtotal).toBe(100)
expect(result.taxAmount).toBe(5)
@@ -29,7 +29,7 @@ describe('calculateTotals', () => {
})
it('handles percentage discount correctly', () => {
- const items = [{ d: 'Item 1', q: 1, r: '100' }]
+ const items = [{ description: 'Item 1', quantity: 1, rate: '100' }]
const result = calculateTotals(items, { discount: '20%' })
expect(result.subtotal).toBe(100)
expect(result.discountAmount).toBe(20)
@@ -37,7 +37,7 @@ describe('calculateTotals', () => {
})
it('handles both tax and discount correctly', () => {
- const items = [{ d: 'Item 1', q: 1, r: '100' }]
+ const items = [{ description: 'Item 1', quantity: 1, rate: '100' }]
const result = calculateTotals(items, { tax: '10%', discount: '10' }) // 100 - 10 + 10 = 100
expect(result.subtotal).toBe(100)
expect(result.taxAmount).toBe(10)
diff --git a/src/widgets/invoice-paper/lib/calculate-totals.ts b/src/widgets/invoice-paper/lib/calculate-totals.ts
index 336e038a..2914c5e7 100644
--- a/src/widgets/invoice-paper/lib/calculate-totals.ts
+++ b/src/widgets/invoice-paper/lib/calculate-totals.ts
@@ -10,8 +10,8 @@ export interface Totals {
}
interface Item {
- q: string | number
- r: string
+ quantity: string | number
+ rate: string
}
interface Options {
@@ -23,8 +23,8 @@ export const MAGIC_DUST = 0.000001
export function calculateTotals(items: Item[], options: Options = {}): Totals {
const subtotal = items.reduce((acc, item) => {
- const qty = typeof item.q === 'string' ? parseFloat(item.q) : item.q
- const rate = parseFloat(item.r)
+ const qty = typeof item.quantity === 'string' ? parseFloat(item.quantity) : item.quantity
+ const rate = parseFloat(item.rate)
return acc + (isNaN(qty) || isNaN(rate) ? 0 : qty * rate)
}, 0)
diff --git a/src/widgets/invoice-paper/types.ts b/src/widgets/invoice-paper/types.ts
index 37985bb9..14b6a11b 100644
--- a/src/widgets/invoice-paper/types.ts
+++ b/src/widgets/invoice-paper/types.ts
@@ -1,4 +1,4 @@
-import { InvoiceSchemaV1, ViewedInvoiceStatus as InvoiceStatus } from '@/entities/invoice'
+import { Invoice, ViewedInvoiceStatus as InvoiceStatus } from '@/entities/invoice'
/**
* Visual status of the invoice document
@@ -27,7 +27,7 @@ export interface InvoicePaperProps {
* Invoice data following Schema V1.
* Can be partial for 'draft' status preview.
*/
- data: Partial
+ data: Partial
/**
* Current status of the invoice.
diff --git a/src/widgets/invoice-paper/ui/InvoicePaper.tsx b/src/widgets/invoice-paper/ui/InvoicePaper.tsx
index 10e2c1e7..3dcf9320 100644
--- a/src/widgets/invoice-paper/ui/InvoicePaper.tsx
+++ b/src/widgets/invoice-paper/ui/InvoicePaper.tsx
@@ -11,8 +11,8 @@ import { NETWORK_SHADOWS } from '@/entities/network/config/ui-config'
import { cn } from '@/shared/lib/utils'
// Stable fallback objects (prevent new object creation on each render)
-const EMPTY_PARTY = { n: '', a: '' } as const
-const EMPTY_CLIENT = { n: '' } as const
+const EMPTY_PARTY = { name: '', walletAddress: '' } as const
+const EMPTY_CLIENT = { name: '' } as const
const EMPTY_ITEMS: never[] = []
// Date formatter singleton
@@ -48,27 +48,27 @@ export const InvoicePaper = React.memo(
) => {
const totals = useMemo(
() =>
- calculateTotals(data.it ?? EMPTY_ITEMS, {
+ calculateTotals(data.items ?? EMPTY_ITEMS, {
tax: data.tax,
- discount: data.dsc,
+ discount: data.discount,
}),
- [data.it, data.tax, data.dsc]
+ [data.items, data.tax, data.discount]
)
- const shadowClass = data.net ? NETWORK_SHADOWS[data.net] : 'shadow-black/20'
+ const shadowClass = data.networkId ? NETWORK_SHADOWS[data.networkId] : 'shadow-black/20'
// Memoize stable props to prevent child re-renders
- const from = data.f ?? EMPTY_PARTY
- const client = data.c ?? EMPTY_CLIENT
- const items = data.it ?? EMPTY_ITEMS
+ const from = data.from ?? EMPTY_PARTY
+ const client = data.client ?? EMPTY_CLIENT
+ const items = data.items ?? EMPTY_ITEMS
// Format date for watermark if paid
const paidDate = useMemo(
() =>
- status === 'paid' && data.iss
- ? dateFormatter.format(new Date(data.iss * 1000)).toUpperCase()
+ status === 'paid' && data.issuedAt
+ ? dateFormatter.format(new Date(data.issuedAt * 1000)).toUpperCase()
: undefined,
- [status, data.iss]
+ [status, data.issuedAt]
)
// Determine if QR should be shown based on variant
@@ -92,7 +92,7 @@ export const InvoicePaper = React.memo(
className
)}
role="document"
- aria-label={`Invoice ${data.id ?? 'draft'}`}
+ aria-label={`Invoice ${data.invoiceId ?? 'draft'}`}
>
{/* Texture Layer - self-hosted for stateless operation */}
{showTexture && (
@@ -105,9 +105,9 @@ export const InvoicePaper = React.memo(
{/* Content Container */}
@@ -121,20 +121,20 @@ export const InvoicePaper = React.memo(
-
+
diff --git a/src/widgets/invoice-paper/ui/InvoicePreviewModal.tsx b/src/widgets/invoice-paper/ui/InvoicePreviewModal.tsx
index 6b14fb56..956a8d6c 100644
--- a/src/widgets/invoice-paper/ui/InvoicePreviewModal.tsx
+++ b/src/widgets/invoice-paper/ui/InvoicePreviewModal.tsx
@@ -5,13 +5,13 @@ import { X } from 'lucide-react'
import { Dialog, DialogContent, DialogTitle, DialogClose } from '@/shared/ui'
import { InvoicePaper } from './InvoicePaper'
import { InvoiceStatus } from '../types'
-import { InvoiceSchemaV1 } from '@/entities/invoice'
+import { Invoice } from '@/entities/invoice'
export interface InvoicePreviewModalProps {
/**
* Invoice data to display
*/
- data: Partial
+ data: Partial
/**
* Invoice status for watermark/badge
@@ -51,7 +51,7 @@ export const InvoicePreviewModal = React.memo(
>
{/* Visually hidden title for accessibility */}
- Invoice Preview {data.id ? `#${data.id}` : ''}
+ Invoice Preview {data.invoiceId ? `#${data.invoiceId}` : ''}
{/* Close button positioned outside the invoice */}
diff --git a/src/widgets/invoice-paper/ui/LineItemsTable.tsx b/src/widgets/invoice-paper/ui/LineItemsTable.tsx
index 52a015f5..4aa68634 100644
--- a/src/widgets/invoice-paper/ui/LineItemsTable.tsx
+++ b/src/widgets/invoice-paper/ui/LineItemsTable.tsx
@@ -1,9 +1,9 @@
import React from 'react'
-import { InvoiceSchemaV1 } from '@/entities/invoice'
+import { Invoice } from '@/entities/invoice'
import { formatAmount, formatRate } from '../lib/format'
interface LineItemsTableProps {
- items: InvoiceSchemaV1['it']
+ items: Invoice['items']
}
export const LineItemsTable = React.memo(({ items }) => {
@@ -30,8 +30,9 @@ export const LineItemsTable = React.memo(({ items }) => {
{items.map((item, idx) => {
- const qty = typeof item.q === 'string' ? parseFloat(item.q) : item.q
- const rate = parseFloat(item.r)
+ const qty =
+ typeof item.quantity === 'string' ? parseFloat(item.quantity) : item.quantity
+ const rate = parseFloat(item.rate)
const amount = isNaN(qty) || isNaN(rate) ? 0 : qty * rate
return (
@@ -40,9 +41,11 @@ export const LineItemsTable = React.memo(({ items }) => {
className="group border-b border-zinc-200 transition-colors last:border-0 even:bg-zinc-50/50 hover:bg-zinc-100/50"
>
{idx + 1} |
- {item.d} |
- {item.q} |
- {formatRate(item.r)} |
+ {item.description} |
+ {item.quantity} |
+
+ {formatRate(item.rate)}
+ |
{formatAmount(amount)}
|
diff --git a/src/widgets/invoice-paper/ui/PartyInfo.tsx b/src/widgets/invoice-paper/ui/PartyInfo.tsx
index 37396840..55090a10 100644
--- a/src/widgets/invoice-paper/ui/PartyInfo.tsx
+++ b/src/widgets/invoice-paper/ui/PartyInfo.tsx
@@ -1,12 +1,11 @@
import React from 'react'
-import { Mail, Phone, MapPin, Wallet } from 'lucide-react'
-import { InvoiceSchemaV1 } from '@/entities/invoice'
-import { CopyButton } from '@/shared/ui'
+import { Invoice } from '@/entities/invoice'
+import { CopyButton, MailIcon, PhoneIcon, MapPinIcon, WalletIcon, HashIcon } from '@/shared/ui'
import { InvoicePaperVariant } from '../types'
interface PartyInfoProps {
- from: InvoiceSchemaV1['f']
- client: InvoiceSchemaV1['c']
+ from: Invoice['from']
+ client: Invoice['client']
variant?: InvoicePaperVariant
}
@@ -22,37 +21,45 @@ export const PartyInfo = React.memo(({ from, client, variant = '
{/* FROM Section - Order: Name → Email → Phone → Address → Wallet */}
From
- {from.n &&
{from.n}
}
+ {from.name &&
{from.name}
}
- {from.e && (
+ {from.email && (
)}
- {from.ph && (
+ {from.phone && (
)}
- {from.ads && (
-
-
+
+ {from.taxId}
+
+ )}
+ {from.physicalAddress && (
+
+
- {from.ads}
+
+ {from.physicalAddress}
+
)}
@@ -63,47 +70,64 @@ export const PartyInfo = React.memo
(({ from, client, variant = '
Bill To
- {client.n && {client.n}
}
+ {client.name && (
+ {client.name}
+ )}
- {client.e && (
+ {client.email && (
)}
- {client.ph && (
+ {client.phone && (
)}
- {client.ads && (
-
-
+
+ {client.taxId}
+
+ )}
+ {client.physicalAddress && (
+
+
- {client.ads}
+
+ {client.physicalAddress}
+
)}
- {client.a && (
-
-
-
- {client.a}
+ {client.walletAddress && (
+
+
+
+ {client.walletAddress}
{isInteractive && (
-
+
)}
)}
diff --git a/src/widgets/invoice-paper/ui/__tests__/InvoicePreviewModal.test.tsx b/src/widgets/invoice-paper/ui/__tests__/InvoicePreviewModal.test.tsx
index 3f99a70e..7ecb285e 100644
--- a/src/widgets/invoice-paper/ui/__tests__/InvoicePreviewModal.test.tsx
+++ b/src/widgets/invoice-paper/ui/__tests__/InvoicePreviewModal.test.tsx
@@ -5,25 +5,27 @@
import { describe, expect, it, vi } from 'vitest'
import { renderWithUser, screen } from '@/shared/ui/__tests__/test-utils'
import { InvoicePreviewModal } from '../InvoicePreviewModal'
-import { InvoiceSchemaV1 } from '@/entities/invoice'
+import { Invoice } from '@/entities/invoice'
// Mock data
-const mockInvoiceData: Partial = {
- id: 'INV-001',
- iss: 1704067200, // 2024-01-01
- due: 1706745600, // 2024-02-01
- net: 1,
- cur: 'USDC',
- dec: 6,
- f: {
- n: 'Acme Corp',
- a: '0x1234567890abcdef1234567890abcdef12345678',
- e: 'billing@acme.com',
+const mockInvoiceData: Partial = {
+ invoiceId: 'INV-001',
+ issuedAt: 1704067200, // 2024-01-01
+ dueAt: 1706745600, // 2024-02-01
+ networkId: 1,
+ currency: 'USDC',
+ decimals: 6,
+ from: {
+ name: 'Acme Corp',
+ walletAddress: '0x1234567890abcdef1234567890abcdef12345678',
+ email: 'billing@acme.com',
+ taxId: 'US12-3456789',
},
- c: {
- n: 'Client Inc',
+ client: {
+ name: 'Client Inc',
+ taxId: 'DE987654321',
},
- it: [{ d: 'Development services', q: 10, r: '100' }],
+ items: [{ description: 'Development services', quantity: 10, rate: '100' }],
}
describe('InvoicePreviewModal', () => {
diff --git a/src/widgets/invoice-paper/ui/__tests__/LineItemsTable.test.tsx b/src/widgets/invoice-paper/ui/__tests__/LineItemsTable.test.tsx
index 0358cde5..5ddfeb55 100644
--- a/src/widgets/invoice-paper/ui/__tests__/LineItemsTable.test.tsx
+++ b/src/widgets/invoice-paper/ui/__tests__/LineItemsTable.test.tsx
@@ -4,8 +4,8 @@ import { LineItemsTable } from '../LineItemsTable'
describe('LineItemsTable', () => {
const mockItems = [
- { d: 'Consulting', q: 1, r: '1000' },
- { d: 'Design', q: 2, r: '500' },
+ { description: 'Consulting', quantity: 1, rate: '1000' },
+ { description: 'Design', quantity: 2, rate: '500' },
]
it('renders table headers', () => {
@@ -34,7 +34,11 @@ describe('LineItemsTable', () => {
it('handles invalid quantity gracefully', () => {
const invalidItems = [
- { d: 'Item with invalid qty', q: 'invalid' as unknown as number, r: '100' },
+ {
+ description: 'Item with invalid qty',
+ quantity: 'invalid' as unknown as number,
+ rate: '100',
+ },
]
render()
// Invalid quantity should result in 0.00 amount
@@ -42,14 +46,16 @@ describe('LineItemsTable', () => {
})
it('handles invalid rate gracefully', () => {
- const invalidItems = [{ d: 'Item with invalid rate', q: 1, r: 'invalid' }]
+ const invalidItems = [{ description: 'Item with invalid rate', quantity: 1, rate: 'invalid' }]
render()
// Invalid rate should result in 0.00 in both rate and amount columns
expect(screen.getAllByText('0.00').length).toBeGreaterThanOrEqual(2)
})
it('handles string quantity correctly', () => {
- const stringQtyItems = [{ d: 'Item', q: '2.5' as unknown as number, r: '100' }]
+ const stringQtyItems = [
+ { description: 'Item', quantity: '2.5' as unknown as number, rate: '100' },
+ ]
render()
// 2.5 * 100 = 250
expect(screen.getByText('250.00')).toBeDefined()
diff --git a/src/widgets/invoice-paper/ui/__tests__/PartyInfo.test.tsx b/src/widgets/invoice-paper/ui/__tests__/PartyInfo.test.tsx
index 1e73bf58..e15ba24d 100644
--- a/src/widgets/invoice-paper/ui/__tests__/PartyInfo.test.tsx
+++ b/src/widgets/invoice-paper/ui/__tests__/PartyInfo.test.tsx
@@ -4,17 +4,21 @@ import { PartyInfo } from '../PartyInfo'
describe('PartyInfo', () => {
const mockFrom = {
- n: 'Sender Company',
- a: '0xsender',
- e: 'sender@example.com',
- ads: '123 Sender St',
+ name: 'Sender Company',
+ walletAddress: '0xsender',
+ email: 'sender@example.com',
+ phone: '+1234567890',
+ physicalAddress: '123 Sender St',
+ taxId: 'US12-3456789',
}
const mockClient = {
- n: 'Client Name',
- a: '0x123...456',
- e: 'client@example.com',
- ads: '123 Street\nCity, Country',
+ name: 'Client Name',
+ walletAddress: '0x123...456',
+ email: 'client@example.com',
+ phone: '+0987654321',
+ physicalAddress: '123 Street\nCity, Country',
+ taxId: 'DE123456789',
}
it('renders both From and Bill To labels', () => {
@@ -38,4 +42,10 @@ describe('PartyInfo', () => {
expect(screen.getByText(/123 Sender St/)).toBeDefined()
expect(screen.getByText(/123 Street/)).toBeDefined()
})
+
+ it('renders tax IDs for both parties', () => {
+ render()
+ expect(screen.getByText('US12-3456789')).toBeDefined()
+ expect(screen.getByText('DE123456789')).toBeDefined()
+ })
})
diff --git a/src/widgets/landing/constants/demo-invoices.ts b/src/widgets/landing/constants/demo-invoices.ts
index 1b4b6343..33a7eb75 100644
--- a/src/widgets/landing/constants/demo-invoices.ts
+++ b/src/widgets/landing/constants/demo-invoices.ts
@@ -2,8 +2,12 @@
* Demo invoice data for landing page rotation
* Feature: 012-landing-page
*
- * All fields of InvoiceSchemaV1 are populated to demonstrate full functionality.
- * Each demo showcases a different invoice status and payment state.
+ * Each demo showcases different optional field combinations:
+ * - ETH: Full profile (all fields) - shows complete invoice
+ * - ARB: Minimal profile (email only) - shows lean freelancer invoice
+ * - OP: Mixed (email, phone, taxId) - shows DAO/nonprofit style
+ * - POLY: Mixed (address, taxId) - shows B2B international style
+ *
* Uses ViewedInvoice type from store for consistency.
*/
@@ -22,37 +26,40 @@ export const DEMO_INVOICES: ViewedInvoice[] = [
txHash: '0xabc123def456789abc123def456789abc123def456789abc123def456789abc1',
txHashValidated: true,
data: {
- v: 1,
- id: 'eth-inv-001',
- iss: BASE_TIMESTAMP,
- due: BASE_TIMESTAMP + 86400 * 14,
- nt: 'Audit report pending final sign-off. Payment due upon delivery.',
- net: 1,
- cur: 'ETH',
- dec: 18,
- f: {
- n: 'EtherScale Solutions',
- a: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F',
- e: 'billing@etherscale.io',
- ads: '548 Market St, Suite 23000\nSan Francisco, CA 94104\nUSA',
- ph: '+1 415 555 0142',
+ version: 2,
+ invoiceId: 'eth-inv-001',
+ issuedAt: BASE_TIMESTAMP,
+ dueAt: BASE_TIMESTAMP + 86400 * 14,
+ notes: 'Audit report pending final sign-off. Payment due upon delivery.',
+ networkId: 1,
+ currency: 'ETH',
+ decimals: 18,
+ from: {
+ name: 'EtherScale Solutions',
+ walletAddress: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F',
+ email: 'billing@etherscale.io',
+ physicalAddress: '548 Market St, Suite 23000\nSan Francisco, CA 94104\nUSA',
+ phone: '+1 415 555 0142',
+ taxId: 'US 12-3456789',
},
- c: {
- n: 'DeFi Frontiers DAO',
- a: '0x2546BcD3c84621e976D8185a91A922aE77ECEc30',
- e: 'treasury@defifrontiers.xyz',
- ads: 'c/o Legal Entity\n123 Blockchain Ave\nZug, Switzerland',
- ph: '+41 41 555 0198',
+ client: {
+ name: 'DeFi Frontiers DAO',
+ walletAddress: '0x2546BcD3c84621e976D8185a91A922aE77ECEc30',
+ email: 'treasury@defifrontiers.xyz',
+ physicalAddress: 'c/o Legal Entity\n123 Blockchain Ave\nZug, Switzerland',
+ phone: '+41 41 555 0198',
+ taxId: 'CHE-123.456.789',
},
- it: [
- { d: 'Smart Contract Security Audit (40 hours)', q: 40, r: '0.125' },
- { d: 'Gas Optimization Consulting (8 hours)', q: 8, r: '0.1' },
+ items: [
+ { description: 'Smart Contract Security Audit (40 hours)', quantity: 40, rate: '0.125' },
+ { description: 'Gas Optimization Consulting (8 hours)', quantity: 8, rate: '0.1' },
],
tax: '0',
- dsc: '5%',
+ discount: '5%',
},
},
// --- Arbitrum (42161) - Game Asset Design [PENDING] ---
+ // Minimal profile: freelancer with just email, client with just wallet
{
invoiceId: 'arb-inv-001',
invoiceUrl:
@@ -60,39 +67,37 @@ export const DEMO_INVOICES: ViewedInvoice[] = [
viewedAt: '2024-01-03T09:30:00.000Z',
status: 'pending',
data: {
- v: 1,
- id: 'arb-inv-001',
- iss: BASE_TIMESTAMP + 86400 * 2,
- due: BASE_TIMESTAMP + 86400 * 32,
- nt: 'Final delivery includes source files and commercial license.',
- net: 42161,
- cur: 'USDC',
- t: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
- dec: 6,
- f: {
- n: 'L2 Design Studio',
- a: '0x3B5c26914569BdF2c8D4e27f0701831F41028751',
- e: 'invoices@l2design.studio',
- ads: '789 Creative Blvd, Unit 4\nAustin, TX 78701\nUSA',
- ph: '+1 512 555 0177',
+ version: 2,
+ invoiceId: 'arb-inv-001',
+ issuedAt: BASE_TIMESTAMP + 86400 * 2,
+ dueAt: BASE_TIMESTAMP + 86400 * 32,
+ notes: 'Final delivery includes source files and commercial license.',
+ networkId: 42161,
+ currency: 'USDC',
+ tokenAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
+ decimals: 6,
+ from: {
+ name: 'L2 Design Studio',
+ walletAddress: '0x3B5c26914569BdF2c8D4e27f0701831F41028751',
+ email: 'invoices@l2design.studio',
+ // Minimal: no phone, address, taxId
},
- c: {
- n: 'ArbGaming Inc.',
- a: '0x99283928B108B736021319727B2B4dD600021c2B',
- e: 'payments@arbgaming.io',
- ads: '456 Gaming Tower, Floor 12\nSingapore 018956',
- ph: '+65 6555 0234',
+ client: {
+ name: 'ArbGaming Inc.',
+ walletAddress: '0x99283928B108B736021319727B2B4dD600021c2B',
+ // Minimal: no email, phone, address, taxId
},
- it: [
- { d: 'Character Sprite Set (10 animations)', q: 1, r: '1200' },
- { d: 'UI Animation Pack (menus, buttons)', q: 1, r: '800' },
- { d: 'Sound Effects Integration', q: 1, r: '400' },
+ items: [
+ { description: 'Character Sprite Set (10 animations)', quantity: 1, rate: '1200' },
+ { description: 'UI Animation Pack (menus, buttons)', quantity: 1, rate: '800' },
+ { description: 'Sound Effects Integration', quantity: 1, rate: '400' },
],
tax: '8%',
- dsc: '200',
+ discount: '200',
},
},
// --- Optimism (10) - Public Goods Grant [PAID + NOT VALIDATED] ---
+ // DAO style: email + phone + taxId (no physical address)
{
invoiceId: 'opt-inv-001',
invoiceUrl:
@@ -102,39 +107,40 @@ export const DEMO_INVOICES: ViewedInvoice[] = [
txHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
txHashValidated: false, // Shows warning indicator
data: {
- v: 1,
- id: 'opt-inv-001',
- iss: BASE_TIMESTAMP + 86400 * 5,
- due: BASE_TIMESTAMP + 86400 * 35,
- nt: 'Thank you for supporting public goods. Milestone 1 of 3.',
- net: 10,
- cur: 'OP',
- t: '0x4200000000000000000000000000000000000042',
- dec: 18,
- f: {
- n: 'Optimistic Builders Collective',
- a: '0x4200000000000000000000000000000000000006',
- e: 'grants@optimisticbuilders.org',
- ads: '1 Public Goods Way\nOptimism City, OP 10001\nDecentralized',
- ph: '+1 800 OPT GOOD',
+ version: 2,
+ invoiceId: 'opt-inv-001',
+ issuedAt: BASE_TIMESTAMP + 86400 * 5,
+ dueAt: BASE_TIMESTAMP + 86400 * 35,
+ notes: 'Thank you for supporting public goods. Milestone 1 of 3.',
+ networkId: 10,
+ currency: 'OP',
+ tokenAddress: '0x4200000000000000000000000000000000000042',
+ decimals: 18,
+ from: {
+ name: 'Optimistic Builders Collective',
+ walletAddress: '0x4200000000000000000000000000000000000006',
+ email: 'grants@optimisticbuilders.org',
+ phone: '+1 800 OPT GOOD',
+ taxId: 'US 55-1234567',
+ // DAO style: no physical address
},
- c: {
- n: 'RetroPGF Foundation',
- a: '0x2501c477D0A35545a387Aa4A3EEe4292A9a8B3F0',
- e: 'disbursements@retropgf.eth',
- ads: 'Optimism Foundation\n123 Collective Drive\nRemote',
- ph: '+1 888 555 0100',
+ client: {
+ name: 'RetroPGF Foundation',
+ email: 'disbursements@retropgf.eth',
+ physicalAddress: 'Optimism Foundation\n123 Collective Drive\nRemote',
+ // Foundation: email + address, no phone/taxId/wallet
},
- it: [
- { d: 'Public Goods Infrastructure Grant - Phase 1', q: 1, r: '15000' },
- { d: 'Community Tooling Development', q: 1, r: '8000' },
- { d: 'Documentation & Onboarding', q: 1, r: '2000' },
+ items: [
+ { description: 'Public Goods Infrastructure Grant - Phase 1', quantity: 1, rate: '15000' },
+ { description: 'Community Tooling Development', quantity: 1, rate: '8000' },
+ { description: 'Documentation & Onboarding', quantity: 1, rate: '2000' },
],
tax: '0',
- dsc: '0',
+ discount: '0',
},
},
// --- Polygon (137) - Data Analytics Service [OVERDUE] ---
+ // B2B international: address + taxId (no email/phone)
{
invoiceId: 'poly-inv-001',
invoiceUrl:
@@ -142,36 +148,36 @@ export const DEMO_INVOICES: ViewedInvoice[] = [
viewedAt: '2024-01-02T16:45:00.000Z',
status: 'overdue',
data: {
- v: 1,
- id: 'poly-inv-001',
- iss: BASE_TIMESTAMP + 86400 * 1,
- due: BASE_TIMESTAMP + 86400 * 15,
- nt: 'Q1 2024 subscription. Auto-renewal unless cancelled 7 days prior.',
- net: 137,
- cur: 'USDC',
- t: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
- dec: 6,
- f: {
- n: 'PolyMarket Analytics Ltd.',
- a: '0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619',
- e: 'billing@polymarketanalytics.com',
- ads: '42 Data Center Road\nMumbai, Maharashtra 400001\nIndia',
- ph: '+91 22 5555 0456',
+ version: 2,
+ invoiceId: 'poly-inv-001',
+ issuedAt: BASE_TIMESTAMP + 86400 * 1,
+ dueAt: BASE_TIMESTAMP + 86400 * 15,
+ notes: 'Q1 2024 subscription. Auto-renewal unless cancelled 7 days prior.',
+ networkId: 137,
+ currency: 'USDC',
+ tokenAddress: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
+ decimals: 6,
+ from: {
+ name: 'PolyMarket Analytics Ltd.',
+ walletAddress: '0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619',
+ physicalAddress: '42 Data Center Road\nMumbai, Maharashtra 400001\nIndia',
+ taxId: 'IN GSTIN29ABCDE1234F1Z5',
+ // B2B formal: address + taxId, no email/phone
},
- c: {
- n: 'Prediction Protocol DAO',
- a: '0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6',
- e: 'finance@predictiondao.io',
- ads: 'DAO Multisig\nGlobal Decentralized Network',
- ph: '+44 20 5555 0789',
+ client: {
+ name: 'Prediction Protocol DAO',
+ walletAddress: '0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6',
+ phone: '+44 20 5555 0789',
+ taxId: 'GB 123456789',
+ // Client: phone + taxId + wallet, no email/address
},
- it: [
- { d: 'Market Data Feed - Premium Tier (Q1)', q: 3, r: '1500' },
- { d: 'API Access - Unlimited Calls', q: 1, r: '500' },
- { d: 'Custom Dashboard Setup', q: 1, r: '750' },
+ items: [
+ { description: 'Market Data Feed - Premium Tier (Q1)', quantity: 3, rate: '1500' },
+ { description: 'API Access - Unlimited Calls', quantity: 1, rate: '500' },
+ { description: 'Custom Dashboard Setup', quantity: 1, rate: '750' },
],
tax: '18%',
- dsc: '10%',
+ discount: '10%',
},
},
]
diff --git a/src/widgets/landing/demo-section/DemoSection.tsx b/src/widgets/landing/demo-section/DemoSection.tsx
index e71346da..f2bb40ad 100644
--- a/src/widgets/landing/demo-section/DemoSection.tsx
+++ b/src/widgets/landing/demo-section/DemoSection.tsx
@@ -36,7 +36,7 @@ export function DemoSection() {
useEffect(() => {
const currentInvoice = DEMO_INVOICES[activeIndex]
if (currentInvoice) {
- setTheme(getNetworkName(currentInvoice.data.net))
+ setTheme(getNetworkName(currentInvoice.data.networkId))
}
}, [activeIndex, setTheme])
@@ -64,7 +64,7 @@ export function DemoSection() {
return
}
- const theme = NETWORK_THEMES[getNetworkName(currentInvoice.data.net)]
+ const theme = NETWORK_THEMES[getNetworkName(currentInvoice.data.networkId)]
return (
{
})
const link = screen.getByRole('link', { name: /use this template/i })
- // First invoice ID is "eth-inv-001"
- expect(link).toHaveAttribute('href', '/create?template=eth-inv-001')
+ // Link should point to /create with encoded invoice data
+ const href = link.getAttribute('href')
+ expect(href).toMatch(/^\/create\?d=/)
})
it('should hide button on mouse leave', async () => {