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 (
+
+ );
+}
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 = (
+
+ );
+ 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 = (
+
+);
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"