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