Copy overhaul + qr designs

This commit is contained in:
2026-07-27 17:54:59 +02:00
parent 033bc7e29d
commit 70d97aa970
144 changed files with 23107 additions and 1699 deletions

View File

@@ -0,0 +1,220 @@
'use client';
import React, { useMemo } from 'react';
import QRCodeLib from 'qrcode';
import {
ModuleShape,
EyeFrameShape,
EyeBallShape,
moduleShapePath,
eyeFramePath,
eyeBallPath,
isInEye,
LOW_COVERAGE_SHAPES,
} from '@/lib/qr-shapes';
export type ErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H';
export interface QRGradient {
type: 'linear' | 'radial';
from: string;
to: string;
/** Degrees, linear only. */
rotation?: number;
}
export interface StyledQRCodeProps {
value: string;
size?: number;
fgColor?: string;
bgColor?: string;
moduleShape?: ModuleShape;
eyeFrameShape?: EyeFrameShape;
eyeBallShape?: EyeBallShape;
/** Overrides fgColor for the module fill when present. */
gradient?: QRGradient | null;
eyeColor?: string | null;
errorCorrection?: ErrorCorrectionLevel;
/** Data URL. Rendered centred, with the modules behind it cleared. */
logoUrl?: string;
/** Logo edge length as a share of the whole code. Capped at 0.28. */
logoScale?: number;
/** Quiet zone in modules. Four is the spec minimum. */
margin?: number;
className?: string;
id?: string;
}
/**
* Effective error correction.
*
* Shapes that fill less of each module, and any logo, both eat into the
* redundancy the scanner relies on. Rather than letting someone print a code
* that fails in the wild, we quietly raise the level - and the UI says so, so
* this is not a hidden override.
*/
export function resolveErrorCorrection(
requested: ErrorCorrectionLevel,
moduleShape: ModuleShape,
hasLogo: boolean
): ErrorCorrectionLevel {
if (hasLogo) return 'H';
if (LOW_COVERAGE_SHAPES.includes(moduleShape)) {
return requested === 'L' || requested === 'M' ? 'Q' : requested;
}
return requested;
}
export default function StyledQRCode({
value,
size = 240,
fgColor = '#000000',
bgColor = '#FFFFFF',
moduleShape = 'square',
eyeFrameShape = 'square',
eyeBallShape = 'square',
gradient = null,
eyeColor = null,
errorCorrection = 'M',
logoUrl,
logoScale = 0.22,
margin = 4,
className,
id,
}: StyledQRCodeProps) {
const gradientId = useMemo(
() => `qrgrad-${Math.random().toString(36).slice(2, 9)}`,
[]
);
const ec = resolveErrorCorrection(errorCorrection, moduleShape, Boolean(logoUrl));
const model = useMemo(() => {
try {
const qr = QRCodeLib.create(value || ' ', { errorCorrectionLevel: ec });
return { size: qr.modules.size, data: qr.modules.data };
} catch {
return null;
}
}, [value, ec]);
if (!model) return null;
const count = model.size;
const total = count + margin * 2;
const cell = size / total;
const offset = margin * cell;
const at = (r: number, c: number): boolean => {
if (r < 0 || c < 0 || r >= count || c >= count) return false;
return model.data[r * count + c] === 1;
};
// Modules that would sit under the logo are dropped rather than painted over.
// Overpainting leaves half-modules at the edge that some scanners still try
// to read; removing them lets the error correction do its job cleanly.
const logoSpan = logoUrl ? Math.min(logoScale, 0.28) : 0;
const logoFrom = logoUrl ? Math.floor((count * (1 - logoSpan)) / 2) : -1;
const logoTo = logoUrl ? Math.ceil((count * (1 + logoSpan)) / 2) : -1;
const underLogo = (r: number, c: number) =>
Boolean(logoUrl) && r >= logoFrom && r < logoTo && c >= logoFrom && c < logoTo;
const modulePaths: string[] = [];
for (let r = 0; r < count; r++) {
for (let c = 0; c < count; c++) {
if (!at(r, c)) continue;
if (isInEye(r, c, count)) continue;
if (underLogo(r, c)) continue;
modulePaths.push(
moduleShapePath(
moduleShape,
offset + c * cell,
offset + r * cell,
cell,
{
top: at(r - 1, c),
bottom: at(r + 1, c),
left: at(r, c - 1),
right: at(r, c + 1),
},
r,
c
)
);
}
}
const eyeOrigins: [number, number][] = [
[0, 0],
[0, count - 7],
[count - 7, 0],
];
const framePaths = eyeOrigins.map(([r, c]) =>
eyeFramePath(eyeFrameShape, offset + c * cell, offset + r * cell, cell * 7)
);
const ballPaths = eyeOrigins.map(([r, c]) =>
eyeBallPath(eyeBallShape, offset + (c + 2) * cell, offset + (r + 2) * cell, cell * 3)
);
const moduleFill = gradient ? `url(#${gradientId})` : fgColor;
const eyeFill = eyeColor || moduleFill;
const logoPx = size * logoSpan;
const logoXY = (size - logoPx) / 2;
return (
<svg
id={id}
className={className}
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
xmlns="http://www.w3.org/2000/svg"
shapeRendering="geometricPrecision"
role="img"
aria-label="QR code"
>
{gradient && (
<defs>
{gradient.type === 'linear' ? (
<linearGradient
id={gradientId}
gradientTransform={`rotate(${gradient.rotation ?? 45} 0.5 0.5)`}
>
<stop offset="0%" stopColor={gradient.from} />
<stop offset="100%" stopColor={gradient.to} />
</linearGradient>
) : (
<radialGradient id={gradientId}>
<stop offset="0%" stopColor={gradient.from} />
<stop offset="100%" stopColor={gradient.to} />
</radialGradient>
)}
</defs>
)}
<rect width={size} height={size} fill={bgColor} />
<path d={modulePaths.join(' ')} fill={moduleFill} fillRule="nonzero" />
{framePaths.map((d, i) => (
<path key={`f${i}`} d={d} fill={eyeFill} fillRule="evenodd" />
))}
{ballPaths.map((d, i) => (
<path key={`b${i}`} d={d} fill={eyeFill} />
))}
{logoUrl && (
<image
href={logoUrl}
x={logoXY}
y={logoXY}
width={logoPx}
height={logoPx}
preserveAspectRatio="xMidYMid meet"
/>
)}
</svg>
);
}