diff --git a/apps/web/app/api/qr/route.tsx b/apps/web/app/api/qr/route.tsx index 37138df19f..68e9ec4253 100644 --- a/apps/web/app/api/qr/route.tsx +++ b/apps/web/app/api/qr/route.tsx @@ -13,11 +13,9 @@ export async function GET(req: NextRequest) { await ratelimitOrThrow(req, "qr"); const params = getSearchParams(req.url); - const { url, size, level, fgColor, bgColor, includeMargin } = + const { url, logo, size, level, fgColor, bgColor, includeMargin } = getQRCodeQuerySchema.parse(params); - // const logo = req.nextUrl.searchParams.get("logo") || "https://assets.dub.co/logo.png"; - return new ImageResponse( QRCodeSVG({ value: url, @@ -26,12 +24,13 @@ export async function GET(req: NextRequest) { includeMargin, fgColor, bgColor, - // imageSettings: { - // src: logo, - // height: size / 4, - // width: size / 4, - // excavate: true, - // }, + imageSettings: { + src: logo, + height: size / 4, + width: size / 4, + excavate: true, + }, + isOGContext: true, }), { width: size, diff --git a/apps/web/lib/qr/api.tsx b/apps/web/lib/qr/api.tsx new file mode 100644 index 0000000000..ac88b82001 --- /dev/null +++ b/apps/web/lib/qr/api.tsx @@ -0,0 +1,87 @@ +import qrcodegen from "./codegen"; +import { + DEFAULT_BGCOLOR, + DEFAULT_FGCOLOR, + DEFAULT_INCLUDEMARGIN, + DEFAULT_LEVEL, + DEFAULT_SIZE, + ERROR_LEVEL_MAP, + MARGIN_SIZE, +} from "./constants"; +import { QRPropsSVG } from "./types"; +import { excavateModules, generatePath, getImageSettings } from "./utils"; + +export async function getQRAsSVG(props: QRPropsSVG) { + const { + value, + size = DEFAULT_SIZE, + level = DEFAULT_LEVEL, + bgColor = DEFAULT_BGCOLOR, + fgColor = DEFAULT_FGCOLOR, + includeMargin = DEFAULT_INCLUDEMARGIN, + imageSettings, + ...otherProps + } = props; + + let cells = qrcodegen.QrCode.encodeText( + value, + ERROR_LEVEL_MAP[level], + ).getModules(); + + const margin = includeMargin ? MARGIN_SIZE : 0; + const numCells = cells.length + margin * 2; + const calculatedImageSettings = getImageSettings( + cells, + size, + includeMargin, + imageSettings, + ); + + let image = <>; + if (imageSettings != null && calculatedImageSettings != null) { + if (calculatedImageSettings.excavation != null) { + cells = excavateModules(cells, calculatedImageSettings.excavation); + } + + const base64Image = await fetch( + `https://wsrv.nl/?url=${imageSettings.src}&w=100&h=100&encoding=base64`, + ).then((res) => res.text()); + + image = ( + + ); + } + + // Drawing strategy: instead of a rect per module, we're going to create a + // single path for the dark modules and layer that on top of a light rect, + // for a total of 2 DOM nodes. We pay a bit more in string concat but that's + // way faster than DOM ops. + // For level 1, 441 nodes -> 2 + // For level 40, 31329 -> 2 + const fgPath = generatePath(cells, margin); + + return ( + + + + {image} + + ); +} diff --git a/apps/web/lib/qr/types.ts b/apps/web/lib/qr/types.ts index 921e75d99e..cbac647afe 100644 --- a/apps/web/lib/qr/types.ts +++ b/apps/web/lib/qr/types.ts @@ -22,6 +22,7 @@ export type QRProps = { style?: CSSProperties; includeMargin?: boolean; imageSettings?: ImageSettings; + isOGContext?: boolean; }; export type QRPropsCanvas = QRProps & React.CanvasHTMLAttributes; diff --git a/apps/web/lib/qr/utils.tsx b/apps/web/lib/qr/utils.tsx index 54a43f75df..a48e203efd 100644 --- a/apps/web/lib/qr/utils.tsx +++ b/apps/web/lib/qr/utils.tsx @@ -116,6 +116,31 @@ export function getImageSettings( return { x, y, h, w, excavation }; } +export function convertImageSettingsToPixels( + calculatedImageSettings: { + x: number; + y: number; + w: number; + h: number; + excavation: Excavation | null; + }, + size: number, + numCells: number, +): { + imgWidth: number; + imgHeight: number; + imgLeft: number; + imgTop: number; +} { + const pixelRatio = size / numCells; + const imgWidth = calculatedImageSettings.w * pixelRatio; + const imgHeight = calculatedImageSettings.h * pixelRatio; + const imgLeft = calculatedImageSettings.x * pixelRatio; + const imgTop = calculatedImageSettings.y * pixelRatio; + + return { imgWidth, imgHeight, imgLeft, imgTop }; +} + export function QRCodeSVG(props: QRPropsSVG) { const { value, @@ -124,13 +149,21 @@ export function QRCodeSVG(props: QRPropsSVG) { bgColor = DEFAULT_BGCOLOR, fgColor = DEFAULT_FGCOLOR, includeMargin = DEFAULT_INCLUDEMARGIN, + isOGContext = false, imageSettings, ...otherProps } = props; + const shouldUseHigherErrorLevel = + isOGContext && imageSettings?.excavate && (level === "L" || level === "M"); + + // Use a higher error correction level 'Q' when excavation is enabled + // to ensure the QR code remains scannable despite the removed modules. + const effectiveLevel = shouldUseHigherErrorLevel ? "Q" : level; + let cells = qrcodegen.QrCode.encodeText( value, - ERROR_LEVEL_MAP[level], + ERROR_LEVEL_MAP[effectiveLevel], ).getModules(); const margin = includeMargin ? MARGIN_SIZE : 0; @@ -148,16 +181,35 @@ export function QRCodeSVG(props: QRPropsSVG) { cells = excavateModules(cells, calculatedImageSettings.excavation); } - image = ( - - ); + if (isOGContext) { + const { imgWidth, imgHeight, imgLeft, imgTop } = + convertImageSettingsToPixels(calculatedImageSettings, size, numCells); + + image = ( + Logo + ); + } else { + image = ( + + ); + } } // Drawing strategy: instead of a rect per module, we're going to create a diff --git a/apps/web/lib/zod/schemas/qr.ts b/apps/web/lib/zod/schemas/qr.ts index 0209a21924..8462af07d8 100644 --- a/apps/web/lib/zod/schemas/qr.ts +++ b/apps/web/lib/zod/schemas/qr.ts @@ -10,6 +10,13 @@ import { parseUrlSchema } from "./utils"; export const getQRCodeQuerySchema = z.object({ url: parseUrlSchema.describe("The URL to generate a QR code for."), + logo: z + .string() + .optional() + .default("https://assets.dub.co/logo.png") + .describe( + "The logo to include in the QR code. Defaults to `https://assets.dub.co/logo.png` if not provided.", + ), size: z.coerce .number() .optional() diff --git a/apps/web/package.json b/apps/web/package.json index f0cc0fc5ae..d0ac4835ff 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,7 +39,7 @@ "@upstash/redis": "^1.25.1", "@vercel/edge-config": "^0.4.1", "@vercel/functions": "^1.4.1", - "@vercel/og": "^0.6.2", + "@vercel/og": "^0.6.3", "@visx/axis": "^2.14.0", "@visx/curve": "^3.3.0", "@visx/event": "^2.6.0", diff --git a/apps/web/ui/modals/link-qr-modal.tsx b/apps/web/ui/modals/link-qr-modal.tsx index 54be7ff901..58afa60a56 100644 --- a/apps/web/ui/modals/link-qr-modal.tsx +++ b/apps/web/ui/modals/link-qr-modal.tsx @@ -25,7 +25,7 @@ import { Hyperlink, Photo, } from "@dub/ui/src/icons"; -import { cn, linkConstructor } from "@dub/utils"; +import { API_DOMAIN, cn, linkConstructor } from "@dub/utils"; import { AnimatePresence, motion } from "framer-motion"; import { Dispatch, @@ -489,13 +489,13 @@ function CopyPopover({ type="button" onClick={() => { navigator.clipboard.writeText( - `https://api.dub.co/qr?url=${linkConstructor({ + `${API_DOMAIN}/qr?url=${linkConstructor({ key: props.key, domain: props.domain, searchParams: { qr: "1", }, - })}`, + })}${qrData.imageSettings?.src ? `&logo=${qrData.imageSettings.src}` : ""}`, ); toast.success("Copied QR code URL to clipboard!"); setCopiedURL(true); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 685e310887..9e131211b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,8 +114,8 @@ importers: specifier: ^1.4.1 version: 1.4.1 '@vercel/og': - specifier: ^0.6.2 - version: 0.6.2 + specifier: ^0.6.3 + version: 0.6.3 '@visx/axis': specifier: ^2.14.0 version: 2.14.0(react@18.2.0) @@ -9643,8 +9643,8 @@ packages: optional: true dev: false - /@vercel/og@0.6.2: - resolution: {integrity: sha512-OTe0KE37F5Y2eTys6eMnfopC+P4qr2ooXUTFyFPTplYSPwowmFk/HLD1FXtbKLjqsIH0SgekcJWad+C5uX4nkg==} + /@vercel/og@0.6.3: + resolution: {integrity: sha512-aoCrC9FqkeA+WEEb9CwSmjD0rGlFeNqbUsI41JPmKWR9Hx6FFn86tvH96O5HZMF6VAXTGHxa3nPH3BokROpdgA==} engines: {node: '>=16'} dependencies: '@resvg/resvg-wasm': 2.4.0