diff --git a/apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts b/apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts index b64ff737343..9f9aea291a7 100644 --- a/apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts +++ b/apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts @@ -346,7 +346,7 @@ export async function POST(req: Request) { where: { partnerId: sourcePartnerId, fraudEventGroup: { - type: FraudRuleType.partnerDuplicatePayoutMethod, + type: FraudRuleType.partnerDuplicateAccount, }, }, include: { @@ -395,7 +395,7 @@ export async function POST(req: Request) { ] : []), ], - type: FraudRuleType.partnerDuplicatePayoutMethod, + type: FraudRuleType.partnerDuplicateAccount, }, resolutionReason: "Automatically resolved because partners with duplicate payout methods were merged. No other partners share this payout method.", diff --git a/apps/web/lib/api/fraud/constants.ts b/apps/web/lib/api/fraud/constants.ts index 891f859d88b..f9e92d3d27b 100644 --- a/apps/web/lib/api/fraud/constants.ts +++ b/apps/web/lib/api/fraud/constants.ts @@ -48,7 +48,7 @@ export const FRAUD_RULES: FraudRuleInfo[] = [ configurable: true, }, { - type: "partnerDuplicatePayoutMethod", + type: "partnerDuplicateAccount", name: "Duplicate account detected", description: "This partner was flagged by our system for having 2 or more Dub accounts. Please review to prevent abuse of program restrictions, caps, or bonuses.", @@ -56,16 +56,6 @@ export const FRAUD_RULES: FraudRuleInfo[] = [ severity: "high", configurable: true, }, - // Not visible in the UI - { - type: "partnerDuplicateAccount", - name: "Duplicate account detected", - description: - "This partner was flagged by our system for having 2 or more Dub accounts. Please review to prevent abuse of program restrictions, caps, or bonuses.", - scope: "partner", - severity: "low", - configurable: false, - }, { type: "partnerEmailDomainMismatch", name: "Email domain mismatch with website", diff --git a/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts b/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts index 977ecc5aac4..3af9bc6ba7e 100644 --- a/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts +++ b/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts @@ -72,7 +72,7 @@ export async function detectDuplicateIdentityFraud({ programEnrollments = programEnrollments.filter((enrollment) => isFraudRuleEnabled({ fraudRules: enrollment.program.fraudRules, - ruleType: FraudRuleType.partnerDuplicatePayoutMethod, // TODO: Change to partnerDuplicateAccount + ruleType: FraudRuleType.partnerDuplicateAccount, }), ); diff --git a/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts b/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts index 7019814a23b..28f8ad7e3a5 100644 --- a/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts +++ b/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts @@ -44,11 +44,11 @@ export async function detectDuplicatePayoutMethodFraud({ return; } - // Filter out program enrollments where the partnerDuplicatePayoutMethod rule is disabled + // Filter out program enrollments where the partnerDuplicateAccount rule is disabled programEnrollments = programEnrollments.filter((enrollment) => isFraudRuleEnabled({ fraudRules: enrollment.program.fraudRules, - ruleType: FraudRuleType.partnerDuplicatePayoutMethod, + ruleType: FraudRuleType.partnerDuplicateAccount, }), ); @@ -89,7 +89,7 @@ export async function detectDuplicatePayoutMethodFraud({ fraudEvents.push({ programId, partnerId: sourcePartner.partnerId, - type: FraudRuleType.partnerDuplicatePayoutMethod, + type: FraudRuleType.partnerDuplicateAccount, metadata: { ...(payoutMethodHash ? { payoutMethodHash } : {}), ...(cryptoWalletAddress ? { cryptoWalletAddress } : {}), diff --git a/apps/web/lib/api/fraud/detect-record-fraud-application.ts b/apps/web/lib/api/fraud/detect-record-fraud-application.ts index 3b73376b5ae..38af7cd10fb 100644 --- a/apps/web/lib/api/fraud/detect-record-fraud-application.ts +++ b/apps/web/lib/api/fraud/detect-record-fraud-application.ts @@ -33,7 +33,7 @@ export async function detectAndRecordFraudApplication({ if ( isFraudRuleEnabled({ fraudRules, - ruleType: FraudRuleType.partnerDuplicatePayoutMethod, + ruleType: FraudRuleType.partnerDuplicateAccount, }) ) { const { payoutMethodHash, cryptoWalletAddress } = partner; @@ -81,7 +81,7 @@ export async function detectAndRecordFraudApplication({ fraudEvents.push({ programId: program.id, partnerId: sourcePartner.id, - type: FraudRuleType.partnerDuplicatePayoutMethod, + type: FraudRuleType.partnerDuplicateAccount, metadata: { ...(payoutMethodHash ? { payoutMethodHash } : {}), ...(cryptoWalletAddress ? { cryptoWalletAddress } : {}), diff --git a/apps/web/lib/api/fraud/execute-fraud-rule.ts b/apps/web/lib/api/fraud/execute-fraud-rule.ts index d8ef692a4da..a7ae12868a2 100644 --- a/apps/web/lib/api/fraud/execute-fraud-rule.ts +++ b/apps/web/lib/api/fraud/execute-fraud-rule.ts @@ -23,9 +23,6 @@ const FRAUD_RULES_REGISTRY: Record< referralSourceBanned: checkReferralSourceBanned, paidTrafficDetected: checkPaidTrafficDetected, partnerCrossProgramBan: defineFraudRuleStub("partnerCrossProgramBan"), - partnerDuplicatePayoutMethod: defineFraudRuleStub( - "partnerDuplicatePayoutMethod", - ), partnerDuplicateAccount: defineFraudRuleStub("partnerDuplicateAccount"), }; diff --git a/apps/web/lib/api/fraud/get-partner-application-risks.ts b/apps/web/lib/api/fraud/get-partner-application-risks.ts index 2108f352d8b..6918be64cdc 100644 --- a/apps/web/lib/api/fraud/get-partner-application-risks.ts +++ b/apps/web/lib/api/fraud/get-partner-application-risks.ts @@ -21,7 +21,7 @@ export async function getPartnerApplicationRisks({ partnerId: partner.id, status: "pending", type: { - in: ["partnerCrossProgramBan", "partnerDuplicatePayoutMethod"], + in: ["partnerCrossProgramBan", "partnerDuplicateAccount"], }, }, }); @@ -30,13 +30,13 @@ export async function getPartnerApplicationRisks({ (group) => group.type === "partnerCrossProgramBan", ); - const hasDuplicatePayoutMethod = fraudGroups.some( - (group) => group.type === "partnerDuplicatePayoutMethod", + const hasDuplicateAccounts = fraudGroups.some( + (group) => group.type === "partnerDuplicateAccount", ); const risksDetected: Partial> = { partnerCrossProgramBan: hasCrossProgramBan, - partnerDuplicatePayoutMethod: hasDuplicatePayoutMethod, + partnerDuplicateAccount: hasDuplicateAccounts, partnerEmailDomainMismatch: checkPartnerEmailDomainMismatch(partner), partnerEmailMasked: checkPartnerEmailMasked(partner), partnerNoSocialLinks: checkPartnerNoSocialLinks(partner), diff --git a/apps/web/lib/api/fraud/utils.ts b/apps/web/lib/api/fraud/utils.ts index f9233b5dcbd..917a76f5a25 100644 --- a/apps/web/lib/api/fraud/utils.ts +++ b/apps/web/lib/api/fraud/utils.ts @@ -89,7 +89,6 @@ function getIdentityFieldsForFraudEvent({ customerId, }; - case "partnerDuplicatePayoutMethod": case "partnerDuplicateAccount": return { duplicatePartnerId: eventMetadata?.duplicatePartnerId, @@ -103,6 +102,9 @@ function getIdentityFieldsForFraudEvent({ return { sourceProgramId, }; + + default: + return {}; } } @@ -136,10 +138,7 @@ export function getPartnerIdForFraudEvent( ) { const metadata = event.metadata as Record | undefined; - if ( - event.type === "partnerDuplicatePayoutMethod" || - event.type === "partnerDuplicateAccount" - ) { + if (event.type === "partnerDuplicateAccount") { return metadata?.duplicatePartnerId ?? event.partnerId; } diff --git a/apps/web/lib/qr/index.tsx b/apps/web/lib/qr/index.tsx index f88ca00f051..9b753ed749f 100644 --- a/apps/web/lib/qr/index.tsx +++ b/apps/web/lib/qr/index.tsx @@ -14,12 +14,22 @@ import { DEFAULT_SIZE, ERROR_LEVEL_MAP, } from "./constants"; -import { QRProps, QRPropsCanvas } from "./types"; +import { + DotStyle, + MarkerBorderStyle, + MarkerCenterStyle, + QRProps, + QRPropsCanvas, +} from "./types"; import { SUPPORTS_PATH2D, excavateModules, + generateExtraRoundedDotPath, generatePath, + generateRoundedDotPath, + generateSquareDotPath, getImageSettings, + isFinderPatternCell, } from "./utils"; export * from "./types"; export * from "./utils"; @@ -40,15 +50,10 @@ export function QRCodeCanvas(props: QRPropsCanvas) { const _canvas = useRef(null); const _image = useRef(null); - // We're just using this state to trigger rerenders when images load. We - // Don't actually read the value anywhere. A smarter use of useEffect would - // depend on this value. // eslint-disable-next-line @typescript-eslint/no-unused-vars const [isImgLoaded, setIsImageLoaded] = useState(false); useEffect(() => { - // Always update the canvas. It's cheap enough and we want to be correct - // with the current state. if (_canvas.current != null) { const canvas = _canvas.current; @@ -84,22 +89,16 @@ export function QRCodeCanvas(props: QRPropsCanvas) { } } - // We're going to scale this so that the number of drawable units - // matches the number of cells. This avoids rounding issues, but does - // result in some potentially unwanted single pixel issues between - // blocks, only in environments that don't support Path2D. const pixelRatio = window.devicePixelRatio || 1; canvas.height = canvas.width = size * pixelRatio; const scale = (size / numCells) * pixelRatio; ctx.scale(scale, scale); - // Draw solid background, only paint dark modules. ctx.fillStyle = bgColor; ctx.fillRect(0, 0, numCells, numCells); ctx.fillStyle = fgColor; if (SUPPORTS_PATH2D) { - // $FlowFixMe: Path2D c'tor doesn't support args yet. ctx.fill(new Path2D(generatePath(cells, margin))); } else { cells.forEach(function (row, rdx) { @@ -123,8 +122,6 @@ export function QRCodeCanvas(props: QRPropsCanvas) { } }); - // Ensure we mark image loaded as false here so we trigger updating the - // canvas in our other effect. useEffect(() => { setIsImageLoaded(false); }, [imgSrc]); @@ -159,6 +156,162 @@ export function QRCodeCanvas(props: QRPropsCanvas) { ); } +// ─── SVG string helpers for download ───────────────────────────────────────── + +function roundedRectPath( + x: number, + y: number, + w: number, + h: number, + r: number, +): string { + return ( + `M${x + r},${y}` + + `H${x + w - r}` + + `A${r},${r} 0 0 1 ${x + w},${y + r}` + + `V${y + h - r}` + + `A${r},${r} 0 0 1 ${x + w - r},${y + h}` + + `H${x + r}` + + `A${r},${r} 0 0 1 ${x},${y + h - r}` + + `V${y + r}` + + `A${r},${r} 0 0 1 ${x + r},${y}` + + `Z` + ); +} + +function circlePath(cx: number, cy: number, r: number): string { + return ( + `M${cx - r},${cy}` + + `A${r},${r} 0 1 0 ${cx + r},${cy}` + + `A${r},${r} 0 1 0 ${cx - r},${cy}` + + `Z` + ); +} + +function finderBorderPathString( + x: number, + y: number, + borderStyle: MarkerBorderStyle, +): string { + const cx = x + 3.5; + const cy = y + 3.5; + if (borderStyle === "square") { + return ( + `M${x},${y}H${x + 7}V${y + 7}H${x}Z ` + + `M${x + 1},${y + 1}H${x + 6}V${y + 6}H${x + 1}Z` + ); + } + if (borderStyle === "rounded-square") { + return ( + roundedRectPath(x, y, 7, 7, 1.5) + + " " + + roundedRectPath(x + 1, y + 1, 5, 5, 0.75) + ); + } + return circlePath(cx, cy, 3.5) + " " + circlePath(cx, cy, 2.5); +} + +function getFinderPatternSVGString({ + x, + y, + markerColor, + borderStyle = "square", + centerStyle = "square", +}: { + x: number; + y: number; + markerColor: string; + bgColor: string; + borderStyle?: MarkerBorderStyle; + centerStyle?: MarkerCenterStyle; +}): string { + const cx = x + 3.5; + const cy = y + 3.5; + + const borderPath = finderBorderPathString(x, y, borderStyle); + const border = ``; + + const center = + centerStyle === "square" + ? `` + : ``; + + return `${border}${center}`; +} + +// ─── Canvas helpers for finder patterns ────────────────────────────────────── + +function drawRoundedRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.arcTo(x + w, y, x + w, y + r, r); + ctx.lineTo(x + w, y + h - r); + ctx.arcTo(x + w, y + h, x + w - r, y + h, r); + ctx.lineTo(x + r, y + h); + ctx.arcTo(x, y + h, x, y + h - r, r); + ctx.lineTo(x, y + r); + ctx.arcTo(x, y, x + r, y, r); + ctx.closePath(); + ctx.fill(); +} + +function drawCanvasFinderPattern( + ctx: CanvasRenderingContext2D, + fx: number, + fy: number, + markerColor: string, + bgColor: string, + borderStyle: MarkerBorderStyle = "square", + centerStyle: MarkerCenterStyle = "square", +) { + const cx = fx + 3.5; + const cy = fy + 3.5; + + // Draw outer border shape + ctx.fillStyle = markerColor; + if (borderStyle === "square") { + ctx.fillRect(fx, fy, 7, 7); + } else if (borderStyle === "rounded-square") { + drawRoundedRect(ctx, fx, fy, 7, 7, 1.5); + } else { + ctx.beginPath(); + ctx.arc(cx, cy, 3.5, 0, Math.PI * 2); + ctx.fill(); + } + + // Punch the gap in a shape that matches the border style + ctx.fillStyle = bgColor; + if (borderStyle === "square") { + ctx.fillRect(fx + 1, fy + 1, 5, 5); + } else if (borderStyle === "rounded-square") { + drawRoundedRect(ctx, fx + 1, fy + 1, 5, 5, 0.75); + } else { + ctx.beginPath(); + ctx.arc(cx, cy, 2.5, 0, Math.PI * 2); + ctx.fill(); + } + + // Draw inner center + ctx.fillStyle = markerColor; + if (centerStyle === "square") { + ctx.fillRect(fx + 2, fy + 2, 3, 3); + } else { + ctx.beginPath(); + ctx.arc(cx, cy, 1.5, 0, Math.PI * 2); + ctx.fill(); + } +} + +// ─── Public download helpers ────────────────────────────────────────────────── + export async function getQRAsSVGDataUri(props: QRProps) { const { value, @@ -168,8 +321,14 @@ export async function getQRAsSVGDataUri(props: QRProps) { fgColor = DEFAULT_FGCOLOR, margin = DEFAULT_MARGIN, imageSettings, + dotStyle = "square", + markerCenterStyle = "square", + markerBorderStyle = "square", + markerColor, } = props; + const effectiveMarkerColor = markerColor ?? fgColor; + let cells = qrcodegen.QrCode.encodeText( value, ERROR_LEVEL_MAP[level], @@ -200,12 +359,38 @@ export async function getQRAsSVGDataUri(props: QRProps) { ].join(" "); } - const fgPath = generatePath(cells, margin); + const fgPath = + dotStyle === "rounded" + ? generateRoundedDotPath(cells, margin) + : dotStyle === "extra-rounded" + ? generateExtraRoundedDotPath(cells, margin) + : generateSquareDotPath(cells, margin); + + const numModules = cells.length; + const finderPositions = [ + { x: margin, y: margin }, + { x: numModules - 7 + margin, y: margin }, + { x: margin, y: numModules - 7 + margin }, + ]; + + const finderSVG = finderPositions + .map((pos) => + getFinderPatternSVGString({ + x: pos.x, + y: pos.y, + markerColor: effectiveMarkerColor, + bgColor, + borderStyle: markerBorderStyle, + centerStyle: markerCenterStyle, + }), + ) + .join(""); const svgData = [ ``, - ``, - ``, + ``, + ``, + finderSVG, image, "", ].join(""); @@ -265,8 +450,14 @@ export async function getQRAsCanvas( fgColor = DEFAULT_FGCOLOR, margin = DEFAULT_MARGIN, imageSettings, + dotStyle = "square", + markerCenterStyle = "square", + markerBorderStyle = "square", + markerColor, } = props; + const effectiveMarkerColor = markerColor ?? fgColor; + const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; @@ -297,24 +488,97 @@ export async function getQRAsCanvas( const scale = (size / numCells) * pixelRatio; ctx.scale(scale, scale); - // Draw solid background, only paint dark modules. + // Background ctx.fillStyle = bgColor; ctx.fillRect(0, 0, numCells, numCells); + // Data dots (excluding finder pattern cells) ctx.fillStyle = fgColor; - if (SUPPORTS_PATH2D) { - // $FlowFixMe: Path2D c'tor doesn't support args yet. - ctx.fill(new Path2D(generatePath(cells, margin))); - } else { - cells.forEach(function (row, rdx) { - row.forEach(function (cell, cdx) { - if (cell) { - ctx.fillRect(cdx + margin, rdx + margin, 1, 1); - } + if (dotStyle === "rounded") { + const r = 0.4; + cells.forEach((row, y) => { + row.forEach((cell, x) => { + if (!cell || isFinderPatternCell(x, y, cells.length)) return; + const px = x + margin; + const py = y + margin; + drawRoundedRect(ctx, px, py, 1, 1, r); + }); + }); + } else if (dotStyle === "extra-rounded") { + const r = 0.5; + const n = cells.length; + const isDark = (xi: number, yi: number) => + xi >= 0 && + yi >= 0 && + xi < n && + yi < n && + !!(cells[yi][xi] && !isFinderPatternCell(xi, yi, n)); + + cells.forEach((row, y) => { + row.forEach((cell, x) => { + if (!cell || isFinderPatternCell(x, y, n)) return; + + const px = x + margin; + const py = y + margin; + const top = isDark(x, y - 1); + const right = isDark(x + 1, y); + const bottom = isDark(x, y + 1); + const left = isDark(x - 1, y); + + // Draw as a rounded rect where connected sides have no arc + const rTL = top || left ? 0 : r; + const rTR = top || right ? 0 : r; + const rBR = bottom || right ? 0 : r; + const rBL = bottom || left ? 0 : r; + + ctx.beginPath(); + ctx.moveTo(px + rTL, py); + ctx.lineTo(px + 1 - rTR, py); + if (rTR > 0) ctx.arcTo(px + 1, py, px + 1, py + rTR, rTR); + ctx.lineTo(px + 1, py + 1 - rBR); + if (rBR > 0) ctx.arcTo(px + 1, py + 1, px + 1 - rBR, py + 1, rBR); + ctx.lineTo(px + rBL, py + 1); + if (rBL > 0) ctx.arcTo(px, py + 1, px, py + 1 - rBL, rBL); + ctx.lineTo(px, py + rTL); + if (rTL > 0) ctx.arcTo(px, py, px + rTL, py, rTL); + ctx.closePath(); + ctx.fill(); }); }); + } else { + if (SUPPORTS_PATH2D) { + ctx.fill(new Path2D(generateSquareDotPath(cells, margin))); + } else { + cells.forEach((row, rdx) => { + row.forEach((cell, cdx) => { + if (cell && !isFinderPatternCell(cdx, rdx, cells.length)) { + ctx.fillRect(cdx + margin, rdx + margin, 1, 1); + } + }); + }); + } } + // Finder patterns + const numModules = cells.length; + const finderPositions = [ + { x: margin, y: margin }, + { x: numModules - 7 + margin, y: margin }, + { x: margin, y: numModules - 7 + margin }, + ]; + + finderPositions.forEach(({ x, y }) => { + drawCanvasFinderPattern( + ctx, + x, + y, + effectiveMarkerColor, + bgColor, + markerBorderStyle, + markerCenterStyle, + ); + }); + const haveImageToRender = calculatedImageSettings != null && image !== null && @@ -345,21 +609,33 @@ export function getQRData({ hideLogo, logo, margin, + dotStyle, + markerCenterStyle, + markerBorderStyle, + markerColor, }: { url: string; fgColor?: string; hideLogo?: boolean; logo?: string; margin?: number; + dotStyle?: DotStyle; + markerCenterStyle?: MarkerCenterStyle; + markerBorderStyle?: MarkerBorderStyle; + markerColor?: string; }) { return { value: `${url}?qr=1`, bgColor: "#ffffff", fgColor, size: 1024, - level: "Q", // QR Code error correction level: https://blog.qrstuff.com/general/qr-code-error-correction + level: "Q", hideLogo, margin, + dotStyle, + markerCenterStyle, + markerBorderStyle, + markerColor, ...(!hideLogo && { imageSettings: { src: logo || DUB_QR_LOGO, diff --git a/apps/web/lib/qr/types.ts b/apps/web/lib/qr/types.ts index 0470554813d..2d73ae9be59 100644 --- a/apps/web/lib/qr/types.ts +++ b/apps/web/lib/qr/types.ts @@ -4,6 +4,19 @@ import qrcodegen from "./codegen"; export type Modules = ReturnType; export type Excavation = { x: number; y: number; w: number; h: number }; +export type DotStyle = "square" | "rounded" | "extra-rounded"; +export type MarkerCenterStyle = "square" | "circle"; +export type MarkerBorderStyle = "square" | "rounded-square" | "circle"; + +export type QRCodeDesign = { + fgColor: string; + hideLogo: boolean; + dotStyle?: DotStyle; + markerCenterStyle?: MarkerCenterStyle; + markerBorderStyle?: MarkerBorderStyle; + markerColor?: string; +}; + export type ImageSettings = { src: string; height: number; @@ -23,6 +36,10 @@ export type QRProps = { style?: CSSProperties; imageSettings?: ImageSettings; isOGContext?: boolean; + dotStyle?: DotStyle; + markerCenterStyle?: MarkerCenterStyle; + markerBorderStyle?: MarkerBorderStyle; + markerColor?: string; }; export type QRPropsCanvas = QRProps & React.CanvasHTMLAttributes; diff --git a/apps/web/lib/qr/utils.tsx b/apps/web/lib/qr/utils.tsx index 3b702845058..db5242dda16 100644 --- a/apps/web/lib/qr/utils.tsx +++ b/apps/web/lib/qr/utils.tsx @@ -8,7 +8,14 @@ import { DEFAULT_SIZE, ERROR_LEVEL_MAP, } from "./constants"; -import { Excavation, ImageSettings, Modules, QRPropsSVG } from "./types"; +import { + Excavation, + ImageSettings, + MarkerBorderStyle, + MarkerCenterStyle, + Modules, + QRPropsSVG, +} from "./types"; import type { JSX } from "react"; @@ -31,14 +38,214 @@ export function excavateModules( }); } +/** + * Returns true if the cell at (x, y) belongs to one of the three 7×7 finder + * pattern zones in the QR code corners. numModules is the raw module count + * (without margin). + */ +export function isFinderPatternCell( + x: number, + y: number, + numModules: number, +): boolean { + return ( + (x < 7 && y < 7) || // top-left + (x >= numModules - 7 && y < 7) || // top-right + (x < 7 && y >= numModules - 7) // bottom-left + ); +} + +/** + * Generates an SVG path string for all dark data modules, skipping the three + * finder pattern regions. Uses the same batched run-length approach as before + * for maximum efficiency. + */ +export function generateSquareDotPath(modules: Modules, margin = 0): string { + const numModules = modules.length; + const ops: Array = []; + modules.forEach(function (row, y) { + // Skip finder pattern rows entirely where possible — still check per cell + // so the separator white modules are handled correctly. + let start: number | null = null; + row.forEach(function (cell, x) { + const isFinder = isFinderPatternCell(x, y, numModules); + + // If we hit a non-dark cell or a finder cell, close any open run. + if ((!cell || isFinder) && start !== null) { + ops.push( + `M${start + margin} ${y + margin}h${x - start}v1H${start + margin}z`, + ); + start = null; + return; + } + + if (x === row.length - 1) { + if (!cell || isFinder) { + return; + } + if (start === null) { + ops.push(`M${x + margin},${y + margin} h1v1H${x + margin}z`); + } else { + ops.push( + `M${start + margin},${y + margin} h${x + 1 - start}v1H${ + start + margin + }z`, + ); + } + return; + } + + if (cell && !isFinder && start === null) { + start = x; + } + }); + }); + return ops.join(""); +} + +/** + * Generates an SVG path string for all dark data modules as rounded squares + * (close to circles), skipping finder pattern cells. + */ +export function generateRoundedDotPath(modules: Modules, margin = 0): string { + const numModules = modules.length; + const r = 0.4; // corner radius — fraction of module size + const ops: Array = []; + + modules.forEach(function (row, y) { + row.forEach(function (cell, x) { + if (!cell || isFinderPatternCell(x, y, numModules)) return; + + const cx = x + margin; + const cy = y + margin; + // Rounded-rectangle arc path for a 1×1 module + ops.push( + `M${cx + r},${cy}` + + `h${1 - 2 * r}` + + `a${r},${r} 0 0 1 ${r},${r}` + + `v${1 - 2 * r}` + + `a${r},${r} 0 0 1 ${-r},${r}` + + `h${-(1 - 2 * r)}` + + `a${r},${r} 0 0 1 ${-r},${-r}` + + `v${-(1 - 2 * r)}` + + `a${r},${r} 0 0 1 ${r},${-r}` + + `z`, + ); + }); + }); + + return ops.join(""); +} + +/** + * Draws a rectangle where each of the four corners can have an independent + * radius (0 = sharp right angle, r = quarter-circle arc). + */ +function cornerRoundedRect( + x: number, + y: number, + w: number, + h: number, + rTL: number, + rTR: number, + rBR: number, + rBL: number, +): string { + const parts: string[] = []; + parts.push(`M${x + rTL},${y}`); + + if (rTR > 0) { + parts.push(`H${x + w - rTR}`); + parts.push(`A${rTR},${rTR} 0 0 1 ${x + w},${y + rTR}`); + } else { + parts.push(`H${x + w}`); + } + + if (rBR > 0) { + parts.push(`V${y + h - rBR}`); + parts.push(`A${rBR},${rBR} 0 0 1 ${x + w - rBR},${y + h}`); + } else { + parts.push(`V${y + h}`); + } + + if (rBL > 0) { + parts.push(`H${x + rBL}`); + parts.push(`A${rBL},${rBL} 0 0 1 ${x},${y + h - rBL}`); + } else { + parts.push(`H${x}`); + } + + if (rTL > 0) { + parts.push(`V${y + rTL}`); + parts.push(`A${rTL},${rTL} 0 0 1 ${x + rTL},${y}`); + } else { + parts.push(`V${y}`); + } + + parts.push("Z"); + return parts.join(""); +} + +/** + * "Extra-rounded" / connected style: each module checks its 4 neighbours. + * Corners that touch a dark neighbour are kept sharp; free corners get a + * full r=0.5 arc — so isolated cells become perfect circles, horizontal + * pairs become pills, and L/T-shapes flow into smooth organic forms. + */ +export function generateExtraRoundedDotPath( + modules: Modules, + margin = 0, +): string { + const numModules = modules.length; + const r = 0.5; + const ops: string[] = []; + + const isDark = (x: number, y: number): boolean => { + if (x < 0 || y < 0 || x >= numModules || y >= numModules) return false; + return !!(modules[y][x] && !isFinderPatternCell(x, y, numModules)); + }; + + modules.forEach((row, y) => { + row.forEach((cell, x) => { + if (!cell || isFinderPatternCell(x, y, numModules)) return; + + const px = x + margin; + const py = y + margin; + + const top = isDark(x, y - 1); + const right = isDark(x + 1, y); + const bottom = isDark(x, y + 1); + const left = isDark(x - 1, y); + + ops.push( + cornerRoundedRect( + px, + py, + 1, + 1, + top || left ? 0 : r, // TL + top || right ? 0 : r, // TR + bottom || right ? 0 : r, // BR + bottom || left ? 0 : r, // BL + ), + ); + }); + }); + + return ops.join(""); +} + +/** + * Legacy path generator kept for use by canvas and SVG download helpers that + * haven't been updated yet. Renders ALL dark modules as squares (no finder + * exclusion). Use the named generators above for new code. + */ export function generatePath(modules: Modules, margin = 0): string { const ops: Array = []; modules.forEach(function (row, y) { let start: number | null = null; row.forEach(function (cell, x) { if (!cell && start !== null) { - // M0 0h7v1H0z injects the space with the move and drops the comma, - // saving a char per operation ops.push( `M${start + margin} ${y + margin}h${x - start}v1H${start + margin}z`, ); @@ -46,18 +253,13 @@ export function generatePath(modules: Modules, margin = 0): string { return; } - // end of row, clean up or skip if (x === row.length - 1) { if (!cell) { - // We would have closed the op above already so this can only mean - // 2+ light modules in a row. return; } if (start === null) { - // Just a single dark module. ops.push(`M${x + margin},${y + margin} h1v1H${x + margin}z`); } else { - // Otherwise finish the current line. ops.push( `M${start + margin},${y + margin} h${x + 1 - start}v1H${ start + margin @@ -97,7 +299,6 @@ export function getImageSettings( const w = (imageSettings.width || defaultSize) * scale; const h = (imageSettings.height || defaultSize) * scale; - // Center the image in the QR code area (without margins) const x = imageSettings.x == null ? qrCodeSize / 2 - w / 2 : imageSettings.x * scale; const y = @@ -136,6 +337,120 @@ export function convertImageSettingsToPixels( return { imgWidth, imgHeight, imgLeft, imgTop }; } +// ─── SVG path helpers for compound finder-pattern shapes ───────────────────── + +/** Closed SVG path for a rounded rectangle. */ +function roundedRectPath( + x: number, + y: number, + w: number, + h: number, + r: number, +): string { + return ( + `M${x + r},${y}` + + `H${x + w - r}` + + `A${r},${r} 0 0 1 ${x + w},${y + r}` + + `V${y + h - r}` + + `A${r},${r} 0 0 1 ${x + w - r},${y + h}` + + `H${x + r}` + + `A${r},${r} 0 0 1 ${x},${y + h - r}` + + `V${y + r}` + + `A${r},${r} 0 0 1 ${x + r},${y}` + + `Z` + ); +} + +/** Closed SVG path for a circle (two half-arc moves). */ +function circlePath(cx: number, cy: number, r: number): string { + return ( + `M${cx - r},${cy}` + + `A${r},${r} 0 1 0 ${cx + r},${cy}` + + `A${r},${r} 0 1 0 ${cx - r},${cy}` + + `Z` + ); +} + +/** + * Returns a compound SVG path for the finder-pattern border ring using the + * evenodd fill rule, so the gap is transparent (no white rectangle needed). + */ +function finderBorderPath( + x: number, + y: number, + borderStyle: MarkerBorderStyle, +): string { + const cx = x + 3.5; + const cy = y + 3.5; + + if (borderStyle === "square") { + const outer = `M${x},${y}H${x + 7}V${y + 7}H${x}Z`; + const inner = `M${x + 1},${y + 1}H${x + 6}V${y + 6}H${x + 1}Z`; + return `${outer} ${inner}`; + } + + if (borderStyle === "rounded-square") { + const outer = roundedRectPath(x, y, 7, 7, 1.5); + // Inner cutout uses a smaller radius so corners look intentional + const inner = roundedRectPath(x + 1, y + 1, 5, 5, 0.75); + return `${outer} ${inner}`; + } + + // circle + const outer = circlePath(cx, cy, 3.5); + const inner = circlePath(cx, cy, 2.5); + return `${outer} ${inner}`; +} + +/** + * Renders a single finder pattern (7×7 zone) at the given top-left corner + * position (in module coordinates, already including margin). + * + * Uses fill-rule="evenodd" so the border ring gap is transparent — no + * background-coloured rectangle is painted, which avoids the hard-cornered + * white square that looks bad on rounded / circle border styles. + */ +function FinderPattern({ + x, + y, + markerColor, + borderStyle = "square", + centerStyle = "square", +}: { + x: number; + y: number; + markerColor: string; + bgColor: string; // kept in signature for API stability; not needed with evenodd + borderStyle?: MarkerBorderStyle; + centerStyle?: MarkerCenterStyle; +}): JSX.Element { + const cx = x + 3.5; + const cy = y + 3.5; + const borderPath = finderBorderPath(x, y, borderStyle); + + return ( + + {/* Border ring — compound path punches a transparent hole via evenodd */} + + + {/* Inner center */} + {centerStyle === "square" && ( + + )} + {centerStyle === "circle" && ( + + )} + + ); +} + export function QRCodeSVG(props: QRPropsSVG) { const { value, @@ -146,14 +461,18 @@ export function QRCodeSVG(props: QRPropsSVG) { margin = DEFAULT_MARGIN, isOGContext = false, imageSettings, + dotStyle = "square", + markerCenterStyle = "square", + markerBorderStyle = "square", + markerColor, ...otherProps } = props; + const effectiveMarkerColor = markerColor ?? fgColor; + 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( @@ -211,13 +530,20 @@ export function QRCodeSVG(props: QRPropsSVG) { } } - // 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); + const fgPath = + dotStyle === "rounded" + ? generateRoundedDotPath(cells, margin) + : dotStyle === "extra-rounded" + ? generateExtraRoundedDotPath(cells, margin) + : generateSquareDotPath(cells, margin); + + // The three finder pattern top-left corners in module coordinates (+ margin) + const numModules = cells.length; + const finderPositions = [ + { x: margin, y: margin }, // top-left + { x: numModules - 7 + margin, y: margin }, // top-right + { x: margin, y: numModules - 7 + margin }, // bottom-left + ]; return ( + {finderPositions.map((pos, i) => ( + + ))} {image} ); diff --git a/apps/web/lib/zod/schemas/fraud.ts b/apps/web/lib/zod/schemas/fraud.ts index 100bb7f0263..e749344e53c 100644 --- a/apps/web/lib/zod/schemas/fraud.ts +++ b/apps/web/lib/zod/schemas/fraud.ts @@ -251,7 +251,6 @@ export const updateFraudRuleSettingsSchema = z.object({ customerEmailMatch: toggleOnlyFraudRuleSchema, customerEmailSuspiciousDomain: toggleOnlyFraudRuleSchema, partnerCrossProgramBan: toggleOnlyFraudRuleSchema, - partnerDuplicatePayoutMethod: toggleOnlyFraudRuleSchema, partnerDuplicateAccount: toggleOnlyFraudRuleSchema, }); @@ -326,8 +325,6 @@ export const fraudEventSchemas = { }), }), - partnerDuplicatePayoutMethod: baseFraudEventSchema, - partnerDuplicateAccount: baseFraudEventSchema, }; diff --git a/apps/web/ui/layout/sidebar/user-dropdown.tsx b/apps/web/ui/layout/sidebar/user-dropdown.tsx index 792be8a9255..ff68091c744 100644 --- a/apps/web/ui/layout/sidebar/user-dropdown.tsx +++ b/apps/web/ui/layout/sidebar/user-dropdown.tsx @@ -10,10 +10,12 @@ import { useCurrentSubdomain, User, } from "@dub/ui"; +import { Gear } from "@dub/ui/icons"; import { APP_DOMAIN, cn, PARTNERS_DOMAIN } from "@dub/utils"; import { LogOut } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; import Link from "next/link"; +import { useParams } from "next/navigation"; import { ComponentPropsWithoutRef, ElementType, @@ -26,6 +28,11 @@ export function UserDropdown() { const { partner } = usePartnerProfile(); const [openPopover, setOpenPopover] = useState(false); const { subdomain } = useCurrentSubdomain(); + const { slug: paramsSlug } = useParams() as { slug?: string | string[] }; + + const workspaceSlug = + (Array.isArray(paramsSlug) ? paramsSlug[0] : paramsSlug) || + session?.user?.["defaultWorkspace"]; const menuOptions = useMemo(() => { const options: Array<{ @@ -53,6 +60,15 @@ export function UserDropdown() { } if (subdomain === "app") { + if (workspaceSlug) { + options.push({ + label: "Workspace settings", + icon: Gear, + href: `/${workspaceSlug}/settings`, + onClick: () => setOpenPopover(false), + }); + } + options.push({ label: "Refer and earn", icon: Gift, @@ -82,7 +98,7 @@ export function UserDropdown() { }); return options; - }, [subdomain, partner, setOpenPopover]); + }, [subdomain, partner, workspaceSlug, setOpenPopover]); return ( diff --git a/apps/web/ui/modals/link-qr-modal.tsx b/apps/web/ui/modals/link-qr-modal.tsx index b898c636bab..e22a0280eff 100644 --- a/apps/web/ui/modals/link-qr-modal.tsx +++ b/apps/web/ui/modals/link-qr-modal.tsx @@ -1,64 +1,33 @@ -import { getQRAsCanvas, getQRAsSVGDataUri, getQRData } from "@/lib/qr"; +import { getQRData } from "@/lib/qr"; +import { QRCodeDesign } from "@/lib/qr/types"; import useDomain from "@/lib/swr/use-domain"; import useWorkspace from "@/lib/swr/use-workspace"; import { QRLinkProps } from "@/lib/types"; -import { QRCode } from "@/ui/shared/qr-code"; import { - Button, - ButtonTooltip, - IconMenu, InfoTooltip, Modal, - Popover, - ShimmerDots, - Switch, Tooltip, TooltipContent, - useCopyToClipboard, useLocalStorage, - useMediaQuery, } from "@dub/ui"; -import { - Check, - Check2, - Copy, - CrownSmall, - Download, - Hyperlink, - Photo, -} from "@dub/ui/icons"; -import { API_DOMAIN, cn, DUB_QR_LOGO, linkConstructor } from "@dub/utils"; -import { AnimatePresence, motion } from "motion/react"; +import { Crown } from "@dub/ui/icons"; +import { DUB_QR_LOGO, linkConstructor } from "@dub/utils"; import { Dispatch, - PropsWithChildren, SetStateAction, useCallback, - useId, useMemo, - useRef, useState, } from "react"; -import { HexColorInput, HexColorPicker } from "react-colorful"; -import { toast } from "sonner"; -import { useDebouncedCallback } from "use-debounce"; import { ProBadgeTooltip } from "../shared/pro-badge-tooltip"; +import { + DEFAULT_QR_DESIGN, + QRCodeDesignFields, + SegmentTab, + SegmentedControl, +} from "./qr-code-design-fields"; -const DEFAULT_COLORS = [ - "#000000", - "#C73E33", - "#DF6547", - "#F4B3D7", - "#F6CF54", - "#49A065", - "#2146B7", - "#AE49BF", -]; - -export type QRCodeDesign = { - fgColor: string; - hideLogo: boolean; -}; +export type { QRCodeDesign }; type LinkQRModalProps = { props: QRLinkProps; @@ -92,32 +61,32 @@ function LinkQRModalInner({ setShowLinkQRModal: Dispatch>; } & LinkQRModalProps) { const { id: workspaceId, slug, plan, logo: workspaceLogo } = useWorkspace(); - const id = useId(); - const { isMobile } = useMediaQuery(); const { logo: domainLogo } = useDomain({ slug: props.domain, enabled: showLinkQRModal, }); - const url = useMemo(() => { - return props.key && props.domain - ? linkConstructor({ key: props.key, domain: props.domain }) - : undefined; - }, [props.key, props.domain]); + const isPro = plan && plan !== "free"; + + const url = useMemo( + () => + props.key && props.domain + ? linkConstructor({ key: props.key, domain: props.domain }) + : undefined, + [props.key, props.domain], + ); const [dataPersisted, setDataPersisted] = useLocalStorage( `qr-code-design-${workspaceId}`, - { - fgColor: "#000000", - hideLogo: false, - }, + DEFAULT_QR_DESIGN, ); - const [data, setData] = useState(dataPersisted); + const [data, setData] = useState(dataPersisted); - const hideLogo = data.hideLogo && plan !== "free"; - const logo = - plan === "free" ? DUB_QR_LOGO : domainLogo || workspaceLogo || DUB_QR_LOGO; + const hideLogo = data.hideLogo && !!isPro; + const logo = !isPro + ? DUB_QR_LOGO + : domainLogo || workspaceLogo || DUB_QR_LOGO; const qrData = useMemo( () => @@ -127,16 +96,15 @@ function LinkQRModalInner({ fgColor: data.fgColor, hideLogo, logo, + dotStyle: data.dotStyle, + markerCenterStyle: data.markerCenterStyle, + markerBorderStyle: data.markerBorderStyle, + markerColor: data.markerColor, }) : null, [url, data, hideLogo, logo], ); - const onColorChange = useDebouncedCallback( - (color: string) => setData((d) => ({ ...d, fgColor: color })), - 500, - ); - return (
+ {/* Header */}

QR Code

@@ -172,376 +140,81 @@ function LinkQRModalInner({
-
-
-
- - QR Code Preview - - -
- {url && qrData && ( -
- -
- - - -
-
- -
- - - -
-
+ setShowLinkQRModal(false)} + logoSection={ +
+
+ +
- )} -
-
- {!isMobile && ( - - )} - {url && ( - - - - - - )} -
-
- - {/* Logo toggle */} -
-
- - -
- { - setData((d) => ({ ...d, hideLogo: !d.hideLogo })); - }} - disabledTooltip={ - !plan || plan === "free" ? ( - - ) : undefined - } - thumbIcon={ - !plan || plan === "free" ? ( - - ) : undefined - } - /> -
- - {/* Color selector */} -
- - QR Code Color - -
-
- - -
- } - > -
- - -
-
- {DEFAULT_COLORS.map((color) => { - const isSelected = data.fgColor === color; - return ( - - ); - })} -
-
-
- -
-
- - ); -} - -function DownloadPopover({ - qrData, - props, - children, -}: PropsWithChildren<{ - qrData: ReturnType; - props: QRLinkProps; -}>) { - const anchorRef = useRef(null); - - function download(url: string, extension: string) { - if (!anchorRef.current) return; - anchorRef.current.href = url; - anchorRef.current.download = `${props.key}-qrcode.${extension}`; - anchorRef.current.click(); - setOpenPopover(false); - } - - const [openPopover, setOpenPopover] = useState(false); - - return ( -
- - - - + setData((d) => ({ ...d, hideLogo: false }))} + disabled={!isPro} + > + Show + + setData((d) => ({ ...d, hideLogo: true }))} + disabled={!isPro} + > + {!isPro && ( + + } + > + + + )} + Hide + +
} - openPopover={openPopover} - setOpenPopover={setOpenPopover} - > - {children} - - {/* This will be used to prompt downloads. */} - -
- ); -} - -function CopyPopover({ - qrData, - props, - children, -}: PropsWithChildren<{ - qrData: ReturnType; - props: QRLinkProps; -}>) { - const [openPopover, setOpenPopover] = useState(false); - const [copiedURL, copyUrlToClipboard] = useCopyToClipboard(2000); - const [copiedImage, copyImageToClipboard] = useCopyToClipboard(2000); - - const copyToClipboard = async () => { - try { - const canvas = await getQRAsCanvas(qrData, "image/png", true); - (canvas as HTMLCanvasElement).toBlob(async function (blob) { - // @ts-ignore - const item = new ClipboardItem({ "image/png": blob }); - await copyImageToClipboard(item); - setOpenPopover(false); - }); - } catch (e) { - throw e; - } - }; - - return ( - - - -
- } - openPopover={openPopover} - setOpenPopover={setOpenPopover} - > - {children} -
+ ); } export function useLinkQRModal(props: LinkQRModalProps) { const [showLinkQRModal, setShowLinkQRModal] = useState(false); - const LinkQRModalCallback = useCallback(() => { - return ( + const LinkQRModalCallback = useCallback( + () => ( - ); - }, [showLinkQRModal, setShowLinkQRModal]); + ), + [showLinkQRModal, setShowLinkQRModal, props], + ); return useMemo( - () => ({ - setShowLinkQRModal, - LinkQRModal: LinkQRModalCallback, - }), + () => ({ setShowLinkQRModal, LinkQRModal: LinkQRModalCallback }), [setShowLinkQRModal, LinkQRModalCallback], ); } diff --git a/apps/web/ui/modals/partner-link-modal.tsx b/apps/web/ui/modals/partner-link-modal.tsx index 46f9da53ccb..ce228bc3efd 100644 --- a/apps/web/ui/modals/partner-link-modal.tsx +++ b/apps/web/ui/modals/partner-link-modal.tsx @@ -98,7 +98,7 @@ function QRCodePreview({ `qr-code-design-program-${programEnrollment?.program?.id}`, { fgColor: "#000000", - logo: logo ?? undefined, + hideLogo: false, }, ); diff --git a/apps/web/ui/modals/partner-link-qr-modal.tsx b/apps/web/ui/modals/partner-link-qr-modal.tsx index d016afb2de1..de16fca7407 100644 --- a/apps/web/ui/modals/partner-link-qr-modal.tsx +++ b/apps/web/ui/modals/partner-link-qr-modal.tsx @@ -1,51 +1,19 @@ -import { getQRAsCanvas, getQRAsSVGDataUri, getQRData } from "@/lib/qr"; +import { getQRData } from "@/lib/qr"; +import { QRCodeDesign } from "@/lib/qr/types"; import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; import { QRLinkProps } from "@/lib/types"; -import { QRCode } from "@/ui/shared/qr-code"; -import { - Button, - ButtonTooltip, - IconMenu, - InfoTooltip, - Modal, - Popover, - ShimmerDots, - Tooltip, - useCopyToClipboard, - useLocalStorage, - useMediaQuery, -} from "@dub/ui"; -import { Check, Copy, Download, Hyperlink, Photo } from "@dub/ui/icons"; -import { API_DOMAIN, linkConstructor } from "@dub/utils"; -import { AnimatePresence, motion } from "motion/react"; +import { InfoTooltip, Modal, useLocalStorage } from "@dub/ui"; +import { linkConstructor } from "@dub/utils"; import { Dispatch, - PropsWithChildren, SetStateAction, useCallback, useMemo, - useRef, useState, } from "react"; -import { HexColorInput, HexColorPicker } from "react-colorful"; -import { toast } from "sonner"; -import { useDebouncedCallback } from "use-debounce"; - -const DEFAULT_COLORS = [ - "#000000", - "#C73E33", - "#DF6547", - "#F4B3D7", - "#F6CF54", - "#49A065", - "#2146B7", - "#AE49BF", -]; +import { DEFAULT_QR_DESIGN, QRCodeDesignFields } from "./qr-code-design-fields"; -export type QRCodeDesign = { - fgColor: string; - logo?: string; -}; +export type { QRCodeDesign }; type PartnerLinkQRModalProps = { props: QRLinkProps; @@ -77,25 +45,23 @@ function PartnerLinkQRModalInner({ showLinkQRModal: boolean; setShowLinkQRModal: Dispatch>; } & PartnerLinkQRModalProps) { - const { isMobile } = useMediaQuery(); const { programEnrollment } = useProgramEnrollment(); const { logo } = programEnrollment?.program ?? {}; - const url = useMemo(() => { - return props.key && props.domain - ? linkConstructor({ key: props.key, domain: props.domain }) - : undefined; - }, [props.key, props.domain]); + const url = useMemo( + () => + props.key && props.domain + ? linkConstructor({ key: props.key, domain: props.domain }) + : undefined, + [props.key, props.domain], + ); const [dataPersisted, setDataPersisted] = useLocalStorage( `qr-code-design-program-${programEnrollment?.program?.id}`, - { - fgColor: "#000000", - logo: logo ?? undefined, - }, + DEFAULT_QR_DESIGN, ); - const [data, setData] = useState(dataPersisted); + const [data, setData] = useState(dataPersisted); const qrData = useMemo( () => @@ -104,16 +70,15 @@ function PartnerLinkQRModalInner({ url, fgColor: data.fgColor, logo: logo ?? undefined, + dotStyle: data.dotStyle, + markerCenterStyle: data.markerCenterStyle, + markerBorderStyle: data.markerBorderStyle, + markerColor: data.markerColor, }) : null, [url, data, logo], ); - const onColorChange = useDebouncedCallback( - (color: string) => setData((d) => ({ ...d, fgColor: color })), - 500, - ); - return (
+ {/* Header */}

QR Code

-
-
-
- - QR Code Preview - - -
- {url && qrData && ( -
- -
- - - -
-
- -
- - - -
-
-
- )} -
-
- {!isMobile && ( - - )} - {url && ( - - - - - - )} -
-
- - {/* Color selector */} -
- - QR Code Color - -
-
- - -
- } - > -
- - -
-
- {DEFAULT_COLORS.map((color) => { - const isSelected = data.fgColor === color; - return ( - - ); - })} -
-
-
- -
-
-
- ); -} - -function DownloadPopover({ - qrData, - props, - children, -}: PropsWithChildren<{ - qrData: ReturnType; - props: QRLinkProps; -}>) { - const anchorRef = useRef(null); - - function download(url: string, extension: string) { - if (!anchorRef.current) return; - anchorRef.current.href = url; - anchorRef.current.download = `${props.key}-qrcode.${extension}`; - anchorRef.current.click(); - setOpenPopover(false); - } - - const [openPopover, setOpenPopover] = useState(false); - - return ( -
- - - - -
- } - openPopover={openPopover} - setOpenPopover={setOpenPopover} - > - {children} - - {/* This will be used to prompt downloads. */} -
setShowLinkQRModal(false)} /> - - ); -} - -function CopyPopover({ - qrData, - props, - children, -}: PropsWithChildren<{ - qrData: ReturnType; - props: QRLinkProps; -}>) { - const [openPopover, setOpenPopover] = useState(false); - const [copiedURL, copyUrlToClipboard] = useCopyToClipboard(2000); - const [copiedImage, copyImageToClipboard] = useCopyToClipboard(2000); - - const copyToClipboard = async () => { - try { - const canvas = await getQRAsCanvas(qrData, "image/png", true); - (canvas as HTMLCanvasElement).toBlob(async function (blob) { - // @ts-ignore - const item = new ClipboardItem({ "image/png": blob }); - await copyImageToClipboard(item); - setOpenPopover(false); - }); - } catch (e) { - throw e; - } - }; - - return ( - - - - - } - openPopover={openPopover} - setOpenPopover={setOpenPopover} - > - {children} - + ); } export function usePartnerLinkQRModal(props: PartnerLinkQRModalProps) { const [showLinkQRModal, setShowLinkQRModal] = useState(false); - const LinkQRModalCallback = useCallback(() => { - return ( + const LinkQRModalCallback = useCallback( + () => ( - ); - }, [showLinkQRModal, setShowLinkQRModal]); + ), + [showLinkQRModal, setShowLinkQRModal, props], + ); return useMemo( - () => ({ - setShowLinkQRModal, - LinkQRModal: LinkQRModalCallback, - }), + () => ({ setShowLinkQRModal, LinkQRModal: LinkQRModalCallback }), [setShowLinkQRModal, LinkQRModalCallback], ); } diff --git a/apps/web/ui/modals/qr-code-design-fields.tsx b/apps/web/ui/modals/qr-code-design-fields.tsx new file mode 100644 index 00000000000..5eba4cdf23b --- /dev/null +++ b/apps/web/ui/modals/qr-code-design-fields.tsx @@ -0,0 +1,826 @@ +import { getQRAsCanvas, getQRAsSVGDataUri, getQRData } from "@/lib/qr"; +import { QRCodeDesign } from "@/lib/qr/types"; +import { QRLinkProps } from "@/lib/types"; +import { QRCode } from "@/ui/shared/qr-code"; +import { + Button, + ButtonTooltip, + IconMenu, + InfoTooltip, + Popover, + ShimmerDots, + Tooltip, + useCopyToClipboard, + useMediaQuery, +} from "@dub/ui"; +import { Check, Check2, Copy, Download, Hyperlink, Photo } from "@dub/ui/icons"; +import { API_DOMAIN, cn, linkConstructor } from "@dub/utils"; +import { AnimatePresence, motion } from "motion/react"; +import { + Dispatch, + PropsWithChildren, + SetStateAction, + useId, + useMemo, + useRef, + useState, +} from "react"; +import { HexColorInput, HexColorPicker } from "react-colorful"; +import { toast } from "sonner"; +import { useDebouncedCallback } from "use-debounce"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +export const DEFAULT_COLORS = [ + "#000000", + "#C73E33", + "#DF6547", + "#F4B3D7", + "#F6CF54", + "#49A065", + "#2146B7", + "#AE49BF", +]; + +export const DEFAULT_QR_DESIGN: QRCodeDesign = { + fgColor: "#000000", + hideLogo: false, + dotStyle: "square", + markerCenterStyle: "square", + markerBorderStyle: "square", +}; + +// ─── Shared form body ───────────────────────────────────────────────────────── + +/** + * The inner content of the QR code design form — preview, style toggles, color + * pickers, and action buttons. Both link and partner modals render this, + * differing only in their header and the optional `logoSection` slot. + */ +export function QRCodeDesignFields({ + data, + setData, + url, + logo, + linkProps, + qrData, + onClose, + logoSection, +}: { + data: QRCodeDesign; + setData: Dispatch>; + url?: string; + logo?: string; + linkProps: QRLinkProps; + qrData: ReturnType | null; + onClose: () => void; + /** Optional slot rendered left of Dot style (link modal uses this for Logo toggle). */ + logoSection?: React.ReactNode; +}) { + const id = useId(); + const { isMobile } = useMediaQuery(); + + const onColorChange = useDebouncedCallback( + (color: string) => setData((d) => ({ ...d, fgColor: color })), + 500, + ); + + const onMarkerColorChange = useDebouncedCallback( + (color: string) => setData((d) => ({ ...d, markerColor: color })), + 500, + ); + + const previewKey = `${data.fgColor}-${data.hideLogo}-${data.dotStyle}-${data.markerCenterStyle}-${data.markerBorderStyle}-${data.markerColor ?? ""}`; + + return ( + <> + {/* Preview */} +
+
+
+ + QR Code Preview + + +
+ {url && qrData && ( +
+ +
+ + + +
+
+ +
+ + + +
+
+
+ )} +
+
+ {!isMobile && ( + + )} + {url && ( + + + + + + )} +
+
+ + {/* Logo slot + Dot style */} +
+ {logoSection} +
+
+ +
+ + setData((d) => ({ ...d, dotStyle: "square" }))} + ariaLabel="Square dots" + > + + + setData((d) => ({ ...d, dotStyle: "rounded" }))} + ariaLabel="Rounded dots" + > + + + + setData((d) => ({ ...d, dotStyle: "extra-rounded" })) + } + ariaLabel="Extra-rounded dots" + > + + + +
+
+ + {/* Marker center + Marker border */} +
+
+
+ +
+ + + setData((d) => ({ ...d, markerCenterStyle: "square" })) + } + ariaLabel="Square marker center" + > + + + + setData((d) => ({ ...d, markerCenterStyle: "circle" })) + } + ariaLabel="Circle marker center" + > + + + +
+ +
+
+ +
+ + + setData((d) => ({ ...d, markerBorderStyle: "square" })) + } + ariaLabel="Square marker border" + > + + + + setData((d) => ({ + ...d, + markerBorderStyle: "rounded-square", + })) + } + ariaLabel="Rounded marker border" + > + + + + setData((d) => ({ ...d, markerBorderStyle: "circle" })) + } + ariaLabel="Circle marker border" + > + + + +
+
+ + setData((d) => ({ ...d, fgColor: color }))} + /> + + + setData((d) => ({ ...d, markerColor: color })) + } + extraAction={ + data.markerColor && data.markerColor !== data.fgColor ? ( + + ) : undefined + } + /> + +
+
+ + ); +} + +// ─── Segmented control ──────────────────────────────────────────────────────── + +export function SegmentedControl({ + activeIndex, + count, + disabled, + children, +}: { + activeIndex: number; + count: number; + disabled?: boolean; + children: React.ReactNode; +}) { + return ( +
+
+ {children} +
+ ); +} + +export function SegmentTab({ + active, + onClick, + disabled, + ariaLabel, + children, +}: { + active: boolean; + onClick: () => void; + disabled?: boolean; + ariaLabel?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +// ─── Color section ──────────────────────────────────────────────────────────── + +export function ColorSection({ + id, + label, + tooltip, + color, + onChange, + onSwatchClick, + extraAction, +}: { + id: string; + label: string; + tooltip?: string; + color: string; + onChange: (color: string) => void; + onSwatchClick: (color: string) => void; + extraAction?: React.ReactNode; +}) { + return ( +
+
+
+ + {label} + + {tooltip && } +
+ {extraAction} +
+
+
+ + +
+ } + > +
+ + +
+
+ {DEFAULT_COLORS.map((swatch) => { + const isSelected = color === swatch; + return ( + + ); + })} +
+
+
+ ); +} + +// ─── Style icons ────────────────────────────────────────────────────────────── + +export function SquareDotIcon() { + return ( + + ); +} + +export function RoundedDotIcon() { + return ( + + ); +} + +export function ExtraRoundedDotIcon() { + return ( + + ); +} + +export function MarkerCenterSquareIcon() { + return ( + + ); +} + +export function MarkerCenterCircleIcon() { + return ( + + ); +} + +export function MarkerBorderSquareIcon() { + return ( + + ); +} + +export function MarkerBorderRoundedIcon() { + return ( + + ); +} + +export function MarkerBorderCircleIcon() { + return ( + + ); +} + +// ─── Download / Copy popovers ───────────────────────────────────────────────── + +export function DownloadPopover({ + qrData, + linkProps, + children, +}: PropsWithChildren<{ + qrData: ReturnType; + linkProps: QRLinkProps; +}>) { + const anchorRef = useRef(null); + const [openPopover, setOpenPopover] = useState(false); + + function download(url: string, extension: string) { + if (!anchorRef.current) return; + anchorRef.current.href = url; + anchorRef.current.download = `${linkProps.key}-qrcode.${extension}`; + anchorRef.current.click(); + setOpenPopover(false); + } + + return ( +
+ + + + +
+ } + openPopover={openPopover} + setOpenPopover={setOpenPopover} + > + {children} + +
+
+ ); +} + +export function CopyPopover({ + qrData, + linkProps, + children, +}: PropsWithChildren<{ + qrData: ReturnType; + linkProps: QRLinkProps; +}>) { + const [openPopover, setOpenPopover] = useState(false); + const [copiedURL, copyUrlToClipboard] = useCopyToClipboard(2000); + const [copiedImage, copyImageToClipboard] = useCopyToClipboard(2000); + + const copyToClipboard = async () => { + const canvas = await getQRAsCanvas(qrData, "image/png", true); + (canvas as HTMLCanvasElement).toBlob(async (blob) => { + // @ts-ignore + const item = new ClipboardItem({ "image/png": blob }); + await copyImageToClipboard(item); + setOpenPopover(false); + }); + }; + + return ( + + + + + } + openPopover={openPopover} + setOpenPopover={setOpenPopover} + > + {children} + + ); +} + +// Re-export for convenience +export { useMemo }; +export type { QRCodeDesign }; diff --git a/apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-partner-info-table.tsx b/apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-partner-info-table.tsx index f3b7a7c6c2a..1e3ba8aff4e 100644 --- a/apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-partner-info-table.tsx +++ b/apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-partner-info-table.tsx @@ -10,7 +10,7 @@ import Link from "next/link"; import * as z from "zod/v4"; type EventDataProps = z.infer< - (typeof fraudEventSchemas)["partnerDuplicatePayoutMethod"] + (typeof fraudEventSchemas)["partnerDuplicateAccount"] >; export function FraudPartnerInfoTable() { diff --git a/apps/web/ui/partners/fraud-risks/fraud-events-tables/index.tsx b/apps/web/ui/partners/fraud-risks/fraud-events-tables/index.tsx index 3ea29fd446f..f5bc9509ecd 100644 --- a/apps/web/ui/partners/fraud-risks/fraud-events-tables/index.tsx +++ b/apps/web/ui/partners/fraud-risks/fraud-events-tables/index.tsx @@ -14,7 +14,6 @@ const FRAUD_EVENTS_TABLES: Partial> = referralSourceBanned: FraudReferralSourceBannedTable, paidTrafficDetected: FraudPaidTrafficDetectedTable, partnerCrossProgramBan: FraudCrossProgramBanTable, - partnerDuplicatePayoutMethod: FraudPartnerInfoTable, partnerDuplicateAccount: FraudPartnerInfoTable, }; diff --git a/apps/web/ui/shared/qr-code.tsx b/apps/web/ui/shared/qr-code.tsx index 2aa59ec7f90..669497ebaef 100644 --- a/apps/web/ui/shared/qr-code.tsx +++ b/apps/web/ui/shared/qr-code.tsx @@ -1,5 +1,6 @@ import { getQRData, QRCodeSVG } from "@/lib/qr"; import { DEFAULT_MARGIN } from "@/lib/qr/constants"; +import { DotStyle, MarkerBorderStyle, MarkerCenterStyle } from "@/lib/qr/types"; import { memo, useMemo } from "react"; export const QRCode = memo( @@ -10,6 +11,10 @@ export const QRCode = memo( logo, scale = 1, margin = DEFAULT_MARGIN, + dotStyle, + markerCenterStyle, + markerBorderStyle, + markerColor, }: { url: string; fgColor?: string; @@ -17,10 +22,35 @@ export const QRCode = memo( logo?: string; scale?: number; margin?: number; + dotStyle?: DotStyle; + markerCenterStyle?: MarkerCenterStyle; + markerBorderStyle?: MarkerBorderStyle; + markerColor?: string; }) => { const qrData = useMemo( - () => getQRData({ url, fgColor, hideLogo, logo, margin }), - [url, fgColor, hideLogo, logo, margin], + () => + getQRData({ + url, + fgColor, + hideLogo, + logo, + margin, + dotStyle, + markerCenterStyle, + markerBorderStyle, + markerColor, + }), + [ + url, + fgColor, + hideLogo, + logo, + margin, + dotStyle, + markerCenterStyle, + markerBorderStyle, + markerColor, + ], ); return ( @@ -31,6 +61,10 @@ export const QRCode = memo( fgColor={qrData.fgColor} level={qrData.level} margin={qrData.margin} + dotStyle={qrData.dotStyle} + markerCenterStyle={qrData.markerCenterStyle} + markerBorderStyle={qrData.markerBorderStyle} + markerColor={qrData.markerColor} {...(qrData.imageSettings && { imageSettings: { ...qrData.imageSettings, diff --git a/packages/prisma/schema/fraud.prisma b/packages/prisma/schema/fraud.prisma index f8313776615..c8bfdcddddd 100644 --- a/packages/prisma/schema/fraud.prisma +++ b/packages/prisma/schema/fraud.prisma @@ -9,7 +9,6 @@ enum FraudRuleType { referralSourceBanned paidTrafficDetected partnerCrossProgramBan // Cross-program ban from other programs - partnerDuplicatePayoutMethod // Duplicate payout method with other partners partnerDuplicateAccount // Duplicate identity with other partners }