diff --git a/.prettierrc b/.prettierrc index 1fb73bc2e2..8d6ce6a7e4 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,5 @@ { "tabWidth": 2, - "singleQuote": true + "singleQuote": true, + "trailingComma": "all" } diff --git a/package.json b/package.json index 37f067f7ba..875ca5acb7 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "clsx": "^2.1.1", "graphql": "^14 || ^15 || ^16", "graphql-request": "^6.1.0", + "qrcode": "^1.5.4", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", "viem": "^2.21.33", @@ -59,6 +60,7 @@ "@storybook/test-runner": "^0.19.1", "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^14.2.0", + "@types/qrcode": "^1", "@types/react": "^18", "@types/react-dom": "^18", "@vitest/coverage-v8": "^2.0.5", diff --git a/src/internal/components/QrCode/QrCodeSvg.test.tsx b/src/internal/components/QrCode/QrCodeSvg.test.tsx new file mode 100644 index 0000000000..3edcdc9bf8 --- /dev/null +++ b/src/internal/components/QrCode/QrCodeSvg.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { cbwSvg } from '../../svg/cbwSvg'; +import { QrCodeSvg } from './QrCodeSvg'; + +vi.mock('../../../core-react/internal/hooks/useTheme', () => ({ + useTheme: vi.fn(() => 'default'), +})); + +describe('QRCodeSVG', () => { + it('renders nothing when no value is provided', () => { + render(); + expect(screen.queryByTitle('QR Code')).toBeNull(); + }); + + it('renders SVG with default props', () => { + render(); + + const svg = screen.getByTitle('QR Code'); + + expect(svg).toBeInTheDocument(); + }); + + it('renders with logo', () => { + render(); + + expect(screen.getByTestId('qr-code-logo')).toBeInTheDocument(); + }); + + it('renders with linear gradient', () => { + render(); + + expect(screen.queryByTestId('linearGrad')).toBeInTheDocument(); + expect(screen.queryByTestId('radialGrad')).toBeNull(); + }); + + it('renders with radial gradient', () => { + render(); + + expect(screen.queryByTestId('radialGrad')).toBeInTheDocument(); + expect(screen.queryByTestId('linearGrad')).not.toBeInTheDocument(); + }); +}); diff --git a/src/internal/components/QrCode/QrCodeSvg.tsx b/src/internal/components/QrCode/QrCodeSvg.tsx new file mode 100644 index 0000000000..e525ab72fa --- /dev/null +++ b/src/internal/components/QrCode/QrCodeSvg.tsx @@ -0,0 +1,183 @@ +import { useId, useMemo } from 'react'; +import { useTheme } from '../../../core-react/internal/hooks/useTheme'; +import { + GRADIENT_END_COORDINATES, + GRADIENT_START_COORDINATES, + QR_CODE_SIZE, + QR_LOGO_BACKGROUND_COLOR, + QR_LOGO_RADIUS, + QR_LOGO_SIZE, + linearGradientStops, + ockThemeToLinearGradientColorMap, + ockThemeToRadiamGradientColorMap, + presetGradients, +} from './gradientConstants'; +import { useCorners } from './useCorners'; +import { useDotsPath } from './useDotsPath'; +import { useLogo } from './useLogo'; +import { useMatrix } from './useMatrix'; + +function coordinateAsPercentage(coordinate: number) { + return `${coordinate * 100}%`; +} + +export type QRCodeSVGProps = { + value: string; + size?: number; + backgroundColor?: string; + logo?: React.ReactNode; + logoSize?: number; + logoBackgroundColor?: string; + logoMargin?: number; + logoBorderRadius?: number; + quietZone?: number; + quietZoneBorderRadius?: number; + ecl?: 'L' | 'M' | 'Q' | 'H'; + gradientType?: 'radial' | 'linear'; +}; + +export function QrCodeSvg({ + value, + size = QR_CODE_SIZE, + backgroundColor = '#ffffff', + logo, + logoSize = QR_LOGO_SIZE, + logoBackgroundColor = QR_LOGO_BACKGROUND_COLOR, + logoMargin = 5, + logoBorderRadius = QR_LOGO_RADIUS, + quietZone = 12, + quietZoneBorderRadius = 10, + ecl = 'Q', + gradientType = 'radial', +}: QRCodeSVGProps) { + const gradientRadius = size * 0.55; + const gradientCenterPoint = size / 2; + const uid = useId(); + + const theme = useTheme(); + const themeName = theme.split('-')[0]; + + const isRadialGradient = gradientType === 'radial'; + const fillColor = isRadialGradient ? `url(#radialGrad-${uid})` : '#000000'; + const bgColor = isRadialGradient + ? backgroundColor + : `url(#linearGrad-${uid})`; + + const linearGradientColor = + ockThemeToLinearGradientColorMap[ + themeName as keyof typeof ockThemeToLinearGradientColorMap + ]; + const linearColors = [ + linearGradientStops[linearGradientColor].startColor, + linearGradientStops[linearGradientColor].endColor, + ]; + + const presetGradientForColor = + presetGradients[ + ockThemeToRadiamGradientColorMap[ + themeName as keyof typeof ockThemeToLinearGradientColorMap + ] as keyof typeof presetGradients + ]; + + const matrix = useMatrix(value, ecl); + const corners = useCorners(size, matrix.length, bgColor, fillColor, uid); + const { x: x1, y: y1 } = GRADIENT_START_COORDINATES; + const { x: x2, y: y2 } = GRADIENT_END_COORDINATES; + + const viewBox = useMemo(() => { + return [ + -quietZone, + -quietZone, + size + quietZone * 2, + size + quietZone * 2, + ].join(' '); + }, [quietZone, size]); + + const svgLogo = useLogo({ + size, + logo, + logoSize, + logoBackgroundColor, + logoMargin, + logoBorderRadius, + }); + + const path = useDotsPath({ + matrix, + size, + logoSize, + logoMargin, + logoBorderRadius, + hasLogo: !!logo, + }); + + if (!path || !value) { + return null; + } + + return ( + + QR Code + + {isRadialGradient ? ( + + {presetGradientForColor.map(([gradientColor, offset]) => ( + + ))} + + ) : ( + + + + + )} + + + + + + + {corners} + {svgLogo} + + + ); +} diff --git a/src/internal/components/QrCode/gradientConstants.test.ts b/src/internal/components/QrCode/gradientConstants.test.ts new file mode 100644 index 0000000000..8167f35bf5 --- /dev/null +++ b/src/internal/components/QrCode/gradientConstants.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { + GRADIENT_END_COORDINATES, + GRADIENT_END_STYLE, + GRADIENT_START_COORDINATES, + QR_CODE_SIZE, + QR_LOGO_BACKGROUND_COLOR, + QR_LOGO_RADIUS, + QR_LOGO_SIZE, + linearGradientStops, + ockThemeToLinearGradientColorMap, + ockThemeToRadiamGradientColorMap, + presetGradients, +} from './gradientConstants'; + +describe('QR Code Constants', () => { + it('should have correct size constants', () => { + expect(QR_CODE_SIZE).toBe(237); + expect(QR_LOGO_SIZE).toBe(50); + expect(QR_LOGO_RADIUS).toBe(10); + }); + + it('should have correct logo background color', () => { + expect(QR_LOGO_BACKGROUND_COLOR).toBe('#ffffff'); + }); + + it('should have correct gradient coordinates', () => { + expect(GRADIENT_START_COORDINATES).toEqual({ x: 0, y: 0 }); + expect(GRADIENT_END_COORDINATES).toEqual({ x: 1, y: 0 }); + }); + + it('should have correct gradient end style', () => { + expect(GRADIENT_END_STYLE).toEqual({ borderRadius: 32 }); + }); +}); + +describe('Theme Maps', () => { + it('should have correct linear gradient theme mappings', () => { + expect(ockThemeToLinearGradientColorMap).toEqual({ + default: 'blue', + base: 'baseBlue', + cyberpunk: 'pink', + hacker: 'black', + }); + }); + + it('should have correct radial gradient theme mappings', () => { + expect(ockThemeToRadiamGradientColorMap).toEqual({ + default: 'default', + base: 'blue', + cyberpunk: 'magenta', + hacker: 'black', + }); + }); +}); + +describe('Linear Gradient Stops', () => { + it('should have correct blue gradient colors', () => { + expect(linearGradientStops.blue).toEqual({ + startColor: '#266EFF', + endColor: '#45E1E5', + }); + }); + + it('should have correct pink gradient colors', () => { + expect(linearGradientStops.pink).toEqual({ + startColor: '#EE5A67', + endColor: '#CE46BD', + }); + }); + + it('should have all required gradient themes', () => { + const expectedThemes = ['blue', 'pink', 'black', 'baseBlue']; + + for (const theme of expectedThemes) { + expect(linearGradientStops[theme]).toBeDefined(); + expect(linearGradientStops[theme].startColor).toBeDefined(); + expect(linearGradientStops[theme].endColor).toBeDefined(); + } + }); +}); + +describe('Preset Gradients', () => { + it('should have correct default gradient stops', () => { + expect(presetGradients.default).toEqual([ + ['#0F27FF', '39.06%'], + ['#6100FF', '76.56%'], + ['#201F1D', '100%'], + ]); + }); + + it('should have all required preset themes', () => { + const expectedPresets = ['default', 'blue', 'magenta', 'black']; + + for (const preset of expectedPresets) { + expect( + presetGradients[preset as keyof typeof presetGradients], + ).toBeDefined(); + expect( + Array.isArray(presetGradients[preset as keyof typeof presetGradients]), + ).toBe(true); + expect( + presetGradients[preset as keyof typeof presetGradients].length, + ).toBe(3); + } + }); + + it('should have valid percentage formats for all gradients', () => { + for (const gradient of Object.values(presetGradients)) { + for (const [_, percentage] of gradient) { + expect(percentage).toMatch(/^\d+(\.\d+)?%$/); + } + } + }); +}); diff --git a/src/internal/components/QrCode/gradientConstants.ts b/src/internal/components/QrCode/gradientConstants.ts new file mode 100644 index 0000000000..c56b89a0ac --- /dev/null +++ b/src/internal/components/QrCode/gradientConstants.ts @@ -0,0 +1,65 @@ +export const QR_CODE_SIZE = 237; +export const QR_LOGO_SIZE = 50; +export const QR_LOGO_RADIUS = 10; +export const QR_LOGO_BACKGROUND_COLOR = '#ffffff'; +export const GRADIENT_START_COORDINATES = { x: 0, y: 0 }; +export const GRADIENT_END_COORDINATES = { x: 1, y: 0 }; +export const GRADIENT_END_STYLE = { borderRadius: 32 }; + +type LinearGradient = { startColor: string; endColor: string }; + +export const ockThemeToLinearGradientColorMap = { + default: 'blue', + base: 'baseBlue', + cyberpunk: 'pink', + hacker: 'black', +}; + +export const ockThemeToRadiamGradientColorMap = { + default: 'default', + base: 'blue', + cyberpunk: 'magenta', + hacker: 'black', +}; + +export const linearGradientStops: Record = { + blue: { + startColor: '#266EFF', + endColor: '#45E1E5', + }, + pink: { + startColor: '#EE5A67', + endColor: '#CE46BD', + }, + black: { + startColor: '#a1a1aa', + endColor: '#27272a', + }, + baseBlue: { + startColor: '#0052ff', + endColor: '#b2cbff', + }, +}; + +export const presetGradients = { + default: [ + ['#0F27FF', '39.06%'], + ['#6100FF', '76.56%'], + ['#201F1D', '100%'], + ], + blue: [ + ['#0F6FFF', '39.06%'], + ['#0F27FF', '76.56%'], + ['#201F1D', '100%'], + ], + magenta: [ + ['#CF00F1', '36.46%'], + ['#7900F1', '68.58%'], + ['#201F1D', '100%'], + ], + black: [ + ['#d4d4d8', '36.46%'], + ['#201F1D', '68.58%'], + ['#201F1D', '100%'], + ], +}; diff --git a/src/internal/components/QrCode/useCorners.test.tsx b/src/internal/components/QrCode/useCorners.test.tsx new file mode 100644 index 0000000000..a2601c027b --- /dev/null +++ b/src/internal/components/QrCode/useCorners.test.tsx @@ -0,0 +1,104 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useCorners } from './useCorners'; +import { CORNER_SIZE } from './useDotsPath'; + +describe('useCorners', () => { + const defaultProps = { + size: 300, + matrixLength: 30, + backgroundColor: '#ffffff', + fillColor: '#000000', + }; + + it('should return SVG group with correct corner elements', () => { + const { result } = renderHook(() => + useCorners( + defaultProps.size, + defaultProps.matrixLength, + defaultProps.backgroundColor, + defaultProps.fillColor, + 'test-uid', + ), + ); + + const svgGroup = result.current; + + expect(svgGroup.type).toBe('g'); + + const children = svgGroup.props.children; + expect(children).toHaveLength(6); + + const rects = children.filter( + (child: JSX.Element) => child.type === 'rect', + ); + expect(rects).toHaveLength(3); + + const circles = children.filter( + (child: JSX.Element) => child.type === 'circle', + ); + expect(circles).toHaveLength(3); + }); + + it('should calculate correct dimensions based on input size', () => { + const { result } = renderHook(() => + useCorners( + defaultProps.size, + defaultProps.matrixLength, + defaultProps.backgroundColor, + defaultProps.fillColor, + 'test-uid', + ), + ); + + const children = result.current.props.children; + const firstRect = children[0]; + const firstCircle = children[3]; + + const expectedRectSize = + (defaultProps.size / defaultProps.matrixLength) * CORNER_SIZE; + expect(firstRect.props.width).toBe(expectedRectSize); + expect(firstRect.props.height).toBe(expectedRectSize); + + const expectedCircleRadius = + (defaultProps.size / defaultProps.matrixLength) * 2; + expect(firstCircle.props.r).toBe(expectedCircleRadius); + }); + + it('should apply correct colors', () => { + const { result } = renderHook(() => + useCorners( + defaultProps.size, + defaultProps.matrixLength, + defaultProps.backgroundColor, + defaultProps.fillColor, + 'test-uid', + ), + ); + + const children = result.current.props.children; + const rect = children[0]; + const circle = children[3]; + + expect(rect.props.fill).toBe(defaultProps.fillColor); + expect(circle.props.stroke).toBe(defaultProps.backgroundColor); + }); + + it('should memoize result when props are unchanged', () => { + const { result, rerender } = renderHook(() => + useCorners( + defaultProps.size, + defaultProps.matrixLength, + defaultProps.backgroundColor, + defaultProps.fillColor, + 'test-uid', + ), + ); + + const firstRender = result.current; + rerender(); + const secondRender = result.current; + + expect(firstRender).toBe(secondRender); + }); +}); diff --git a/src/internal/components/QrCode/useCorners.tsx b/src/internal/components/QrCode/useCorners.tsx new file mode 100644 index 0000000000..e7f7f1f278 --- /dev/null +++ b/src/internal/components/QrCode/useCorners.tsx @@ -0,0 +1,85 @@ +import { useMemo } from 'react'; +import { CORNER_SIZE } from './useDotsPath'; + +export function useCorners( + size: number, + matrixLength: number, + backgroundColor: string, + fillColor: string, + uid: string, +) { + const dotSize = size / matrixLength; + const rectSize = dotSize * CORNER_SIZE; + const circleRadius = dotSize * 2; + const circleStrokeWidth = dotSize + 1; + const corners = useMemo( + () => ( + + + + + + + + + ), + [ + backgroundColor, + circleRadius, + circleStrokeWidth, + fillColor, + rectSize, + size, + uid, + ], + ); + return corners; +} diff --git a/src/internal/components/QrCode/useDotsPath.test.ts b/src/internal/components/QrCode/useDotsPath.test.ts new file mode 100644 index 0000000000..99c4f98e54 --- /dev/null +++ b/src/internal/components/QrCode/useDotsPath.test.ts @@ -0,0 +1,94 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useDotsPath } from './useDotsPath'; + +describe('useDotsPath', () => { + const defaultProps = { + matrix: [ + [1, 0, 1], + [0, 1, 0], + [1, 0, 1], + ], + size: 300, + logoSize: 60, + logoMargin: 5, + logoBorderRadius: 0, + hasLogo: false, + }; + + it('should generate path for simple matrix without logo', () => { + const { result } = renderHook(() => useDotsPath(defaultProps)); + + expect(result.current).toContain('M'); + expect(result.current).toContain('A'); + }); + + it('should skip logo area when hasLogo is true', () => { + const withLogo = { + ...defaultProps, + hasLogo: true, + matrix: [ + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + ], + }; + + const { result: withLogoResult } = renderHook(() => useDotsPath(withLogo)); + const { result: withoutLogoResult } = renderHook(() => + useDotsPath({ ...withLogo, hasLogo: false }), + ); + + expect(withLogoResult.current.length).toBeLessThan( + withoutLogoResult.current.length, + ); + }); + + it('should handle round logos', () => { + const withRoundLogo = { + ...defaultProps, + size: 300, + hasLogo: true, + logoBorderRadius: 40, + logoSize: 60, + logoMargin: 5, + matrix: Array(25).fill(Array(25).fill(1)), + }; + + const { result: roundLogoResult } = renderHook(() => + useDotsPath(withRoundLogo), + ); + const { result: squareLogoResult } = renderHook(() => + useDotsPath({ ...withRoundLogo, logoBorderRadius: 0 }), + ); + + expect(roundLogoResult.current).not.toBe(squareLogoResult.current); + }); + + it('should skip masked cells in corners', () => { + const largeMatrix = Array(21).fill(Array(21).fill(1)); + const { result } = renderHook(() => + useDotsPath({ + ...defaultProps, + matrix: largeMatrix, + }), + ); + + const points = result.current.split('M').filter(Boolean); + + expect(points.length).toBeLessThan(21 * 21); + }); + + it('should handle empty matrix', () => { + const { result } = renderHook(() => + useDotsPath({ + ...defaultProps, + matrix: [], + }), + ); + + expect(result.current).toBe(''); + }); +}); diff --git a/src/internal/components/QrCode/useDotsPath.ts b/src/internal/components/QrCode/useDotsPath.ts new file mode 100644 index 0000000000..ca53d446cb --- /dev/null +++ b/src/internal/components/QrCode/useDotsPath.ts @@ -0,0 +1,147 @@ +import { useMemo } from 'react'; + +type LogoConfig = { + hasLogo: boolean; + logoSize: number; + logoMargin: number; + logoBorderRadius: number; + matrixLength: number; + dotSize: number; +}; + +const squareMask = [ + [1, 1, 1, 1, 1, 1, 1], + [1, 0, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 0, 1], + [1, 1, 1, 1, 1, 1, 1], +]; + +const dotMask = [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 1, 1, 0, 0], + [0, 0, 1, 1, 1, 0, 0], + [0, 0, 1, 1, 1, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], +]; + +function getDistance(x1: number, y1: number, x2: number, y2: number) { + return Math.sqrt((y2 - y1) ** 2 + (x2 - x1) ** 2); +} + +function shouldSkipMaskedCell( + i: number, + j: number, + matrixLength: number, +): boolean { + return Boolean( + squareMask[i]?.[j] || + squareMask[i - matrixLength + CORNER_SIZE]?.[j] || + squareMask[i]?.[j - matrixLength + CORNER_SIZE] || + dotMask[i]?.[j] || + dotMask[i - matrixLength + CORNER_SIZE]?.[j] || + dotMask[i]?.[j - matrixLength + CORNER_SIZE], + ); +} + +function shouldSkipLogoArea( + i: number, + j: number, + { + hasLogo, + logoSize, + logoMargin, + logoBorderRadius, + matrixLength, + dotSize, + }: LogoConfig, +): boolean { + if (!hasLogo) { + return false; + } + + const logoAndMarginTotalSize = logoSize + logoMargin * 2; + const logoSizeInDots = logoAndMarginTotalSize / dotSize; + const midpoint = Math.floor(matrixLength / 2); + const isRoundLogo = logoBorderRadius >= logoSize / 2; + + if (isRoundLogo) { + const logoRadiusInDots = logoSizeInDots / 2; + const distFromMiddleInDots = getDistance(j, i, midpoint, midpoint); + return distFromMiddleInDots - 0.5 <= logoRadiusInDots; + } + + const numDotsOffCenterToHide = Math.ceil(logoSizeInDots / 2); + return ( + i <= midpoint + numDotsOffCenterToHide && + i >= midpoint - numDotsOffCenterToHide && + j <= midpoint + numDotsOffCenterToHide && + j >= midpoint - numDotsOffCenterToHide + ); +} + +function getDotPath(centerX: number, centerY: number, radius: number): string { + return ` + M ${centerX - radius} ${centerY} + A ${radius} ${radius} 0 1 1 ${centerX + radius} ${centerY} + A ${radius} ${radius} 0 1 1 ${centerX - radius} ${centerY}`; +} + +export const CORNER_SIZE = 7; + +type UseDotsPathProps = { + matrix: number[][]; + size: number; + logoSize: number; + logoMargin: number; + logoBorderRadius: number; + hasLogo: boolean; +}; + +export function useDotsPath({ + matrix, + size, + logoSize, + logoMargin, + logoBorderRadius, + hasLogo, +}: UseDotsPathProps): string { + const dotsPath = useMemo(() => { + const cellSize = size / matrix.length; + let path = ''; + const matrixLength = matrix.length; + const dotSize = size / matrixLength; + + matrix.forEach((row, i) => { + row.forEach((column, j) => { + if ( + shouldSkipMaskedCell(i, j, matrixLength) || + shouldSkipLogoArea(i, j, { + hasLogo, + logoSize, + logoMargin, + logoBorderRadius, + matrixLength, + dotSize, + }) + ) { + return; + } + + if (column) { + const centerX = cellSize * j + cellSize / 2; + const centerY = cellSize * i + cellSize / 2; + path += getDotPath(centerX, centerY, cellSize / 2); + } + }); + }); + + return path; + }, [hasLogo, logoBorderRadius, logoMargin, logoSize, matrix, size]); + + return dotsPath; +} diff --git a/src/internal/components/QrCode/useLogo.test.tsx b/src/internal/components/QrCode/useLogo.test.tsx new file mode 100644 index 0000000000..bcb595a51e --- /dev/null +++ b/src/internal/components/QrCode/useLogo.test.tsx @@ -0,0 +1,87 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useLogo } from './useLogo'; + +describe('useLogo', () => { + const defaultProps = { + size: 300, + logo: undefined, + logoSize: 60, + logoBackgroundColor: 'white', + logoMargin: 5, + logoBorderRadius: 0, + }; + + it('should render default logo when no logo provided', () => { + const { result } = renderHook(() => useLogo(defaultProps)); + + expect(result.current.props.transform).toContain('translate'); + expect(result.current.type).toBe('g'); + + const imageElement = result.current.props.children[2].props.children; + expect(imageElement.props.href).toContain('data:image/svg+xml'); + expect(imageElement.props.href).toContain(encodeURIComponent('<')); + }); + + it('should handle custom React SVG element', () => { + const customLogo = ( + + Custom Logo + + + ); + const { result } = renderHook(() => + useLogo({ ...defaultProps, logo: customLogo }), + ); + + const imageElement = result.current.props.children[2].props.children; + expect(imageElement.props.href).toContain('data:image/svg+xml'); + expect(imageElement.props.href).toContain(encodeURIComponent('circle')); + }); + + it('should apply correct positioning and sizing', () => { + const { result } = renderHook(() => useLogo(defaultProps)); + + const expectedPosition = (300 - 60 - 5 * 2) / 2; + expect(result.current.props.transform).toBe( + `translate(${expectedPosition}, ${expectedPosition})`, + ); + + const backgroundRect = result.current.props.children[1].props.children; + expect(backgroundRect.props.width).toBe(70); + expect(backgroundRect.props.height).toBe(70); + }); + + it('should apply border radius correctly', () => { + const borderRadius = 10; + const { result } = renderHook(() => + useLogo({ ...defaultProps, logoBorderRadius: borderRadius }), + ); + + const clipPathRect = + result.current.props.children[0].props.children.props.children; + expect(clipPathRect.props.rx).toBe(borderRadius); + expect(clipPathRect.props.ry).toBe(borderRadius); + + const backgroundRect = result.current.props.children[1].props.children; + expect(backgroundRect.props.rx).toBe(borderRadius); + expect(backgroundRect.props.ry).toBe(borderRadius); + }); + + it('should apply correct background color', () => { + const backgroundColor = '#ff0000'; + const { result } = renderHook(() => + useLogo({ ...defaultProps, logoBackgroundColor: backgroundColor }), + ); + + const backgroundRect = result.current.props.children[1].props.children; + expect(backgroundRect.props.fill).toBe(backgroundColor); + }); + + it('should preserve aspect ratio in image', () => { + const { result } = renderHook(() => useLogo(defaultProps)); + + const imageElement = result.current.props.children[2].props.children; + expect(imageElement.props.preserveAspectRatio).toBe('xMidYMid slice'); + }); +}); diff --git a/src/internal/components/QrCode/useLogo.tsx b/src/internal/components/QrCode/useLogo.tsx new file mode 100644 index 0000000000..3efa8b1104 --- /dev/null +++ b/src/internal/components/QrCode/useLogo.tsx @@ -0,0 +1,71 @@ +import React, { useMemo } from 'react'; +import ReactDOMServer from 'react-dom/server'; +import { cbwSvg } from '../../svg/cbwSvg'; + +type RenderLogoProps = { + size: number; + logo: { uri: string } | React.ReactNode | undefined; + logoSize: number; + logoBackgroundColor: string; + logoMargin: number; + logoBorderRadius: number; +}; + +const defaultSvgString = ReactDOMServer.renderToString(cbwSvg); +const defaultSvgDataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( + defaultSvgString, +)}`; + +export function useLogo({ + size, + logo = defaultSvgDataUri, + logoSize, + logoBackgroundColor, + logoMargin, + logoBorderRadius, +}: RenderLogoProps) { + const svgLogo = useMemo(() => { + if (React.isValidElement(logo)) { + logo = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( + ReactDOMServer.renderToString(logo), + )}`; + } + const logoPosition = (size - logoSize - logoMargin * 2) / 2; + const logoBackgroundSize = logoSize + logoMargin * 2; + + return ( + + + + + + + + + + + + ); + }, [logo, logoBackgroundColor, logoBorderRadius, logoMargin, logoSize, size]); + return svgLogo; +} diff --git a/src/internal/components/QrCode/useMatrix.test.ts b/src/internal/components/QrCode/useMatrix.test.ts new file mode 100644 index 0000000000..7790834981 --- /dev/null +++ b/src/internal/components/QrCode/useMatrix.test.ts @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useMatrix } from './useMatrix'; + +describe('useMatrix', () => { + it('returns empty array when value is empty', () => { + const { result } = renderHook(() => useMatrix('', 'L')); + expect(result.current).toEqual([]); + }); + + it('generates correct QR matrix for simple value', () => { + const { result } = renderHook(() => useMatrix('test', 'L')); + + expect(Array.isArray(result.current)).toBe(true); + expect(result.current.length).toBeGreaterThan(0); + expect(result.current[0].length).toBe(result.current.length); + + expect( + result.current.every((row) => + row.every((cell) => cell === 0 || cell === 1), + ), + ).toBe(true); + }); + + it('generates different matrices for different error correction levels', () => { + const { result: resultL } = renderHook(() => useMatrix('test', 'L')); + const { result: resultH } = renderHook(() => useMatrix('test', 'H')); + + expect(resultL.current).not.toEqual(resultH.current); + }); + + it('memoizes result for same inputs', () => { + const { result, rerender } = renderHook( + ({ value, level }) => useMatrix(value, level), + { + initialProps: { value: 'test', level: 'L' as const }, + }, + ); + + const firstResult = result.current; + rerender({ value: 'test', level: 'L' }); + + expect(result.current).toBe(firstResult); // Same reference + }); +}); diff --git a/src/internal/components/QrCode/useMatrix.ts b/src/internal/components/QrCode/useMatrix.ts new file mode 100644 index 0000000000..e816662f7c --- /dev/null +++ b/src/internal/components/QrCode/useMatrix.ts @@ -0,0 +1,25 @@ +import QRCode from 'qrcode'; +import { useMemo } from 'react'; + +export function useMatrix( + value: string, + errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H', +) { + const matrix = useMemo(() => { + if (!value) { + return []; + } + + const arr = Array.from( + QRCode.create(value, { errorCorrectionLevel }).modules.data, + ); + + const sqrt = Math.sqrt(arr.length); + + return arr.reduce((rows, key, index) => { + index % sqrt === 0 ? rows.push([key]) : rows[rows.length - 1].push(key); + return rows; + }, []); + }, [errorCorrectionLevel, value]); + return matrix; +} diff --git a/src/internal/svg/cbwSvg.tsx b/src/internal/svg/cbwSvg.tsx new file mode 100644 index 0000000000..ad1196dbac --- /dev/null +++ b/src/internal/svg/cbwSvg.tsx @@ -0,0 +1,17 @@ +export const cbwSvg = ( + + Wallet Icon + + + +); diff --git a/yarn.lock b/yarn.lock index fb9b0981d2..42cc0b6813 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2145,6 +2145,7 @@ __metadata: "@tanstack/react-query": "npm:^5" "@testing-library/jest-dom": "npm:^6.4.6" "@testing-library/react": "npm:^14.2.0" + "@types/qrcode": "npm:^1" "@types/react": "npm:^18" "@types/react-dom": "npm:^18" "@vitest/coverage-v8": "npm:^2.0.5" @@ -2157,6 +2158,7 @@ __metadata: graphql-request: "npm:^6.1.0" jsdom: "npm:^24.1.0" packemon: "npm:3.3.1" + qrcode: "npm:^1.5.4" react: "npm:^18" react-dom: "npm:^18" rimraf: "npm:^5.0.5" @@ -5360,6 +5362,15 @@ __metadata: languageName: node linkType: hard +"@types/qrcode@npm:^1": + version: 1.5.5 + resolution: "@types/qrcode@npm:1.5.5" + dependencies: + "@types/node": "npm:*" + checksum: b8e6709905d1edb32dda414408acab18ac4aefcbe7bf96d9e32ba94218f45b99c8938ba7a09863ce82a67b226195099fd0f48881d16ee844899087b7f249955f + languageName: node + linkType: hard + "@types/qs@npm:*": version: 6.9.15 resolution: "@types/qs@npm:6.9.15" @@ -13196,6 +13207,19 @@ __metadata: languageName: node linkType: hard +"qrcode@npm:^1.5.4": + version: 1.5.4 + resolution: "qrcode@npm:1.5.4" + dependencies: + dijkstrajs: "npm:^1.0.1" + pngjs: "npm:^5.0.0" + yargs: "npm:^15.3.1" + bin: + qrcode: bin/qrcode + checksum: ae1d57c9cff6099639a590b432c71b15e3bd3905ce4353e6d00c95dee6bb769a8f773f6a7575ecc1b8ed476bf79c5138a4a65cb380c682de3b926d7205d34d10 + languageName: node + linkType: hard + "qs@npm:6.11.0": version: 6.11.0 resolution: "qs@npm:6.11.0"