diff --git a/README.md b/README.md index 3698a1f..99ae078 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Here is the list of all utilities: - [Lorem Ipsum Generator](https://jam.dev/utilities/lorem-ipsum-generator) - [WebP Converter](https://jam.dev/utilities/webp-converter) - [SQL Minifer](https://jam.dev/utilities/sql-minifier) +- [QR Code Generator](https://jam.dev/utilities/qr-code-generator) ### Built With diff --git a/components/seo/QrCodeSEO.tsx b/components/seo/QrCodeSEO.tsx new file mode 100644 index 0000000..e2f5036 --- /dev/null +++ b/components/seo/QrCodeSEO.tsx @@ -0,0 +1,247 @@ +import CodeExample from "../CodeExample"; +import GetJamForFree from "./GetJamForFree"; + +export default function QrCodeSEO() { + return ( +
+
+

+ Create customizable QR codes with our free online generator. Add your + logo, choose different styles (dots, squares, rounded corners), and + customize colors. Perfect for marketing materials, business cards, and + digital campaigns. No sign-up required — generate and download + instantly. Made with 💜 by the developers building Jam. +

+
+ +
+

How to Use the QR Code Generator:

+

+ Generate professional QR codes in seconds. Enter your text, URL, or + data, customize the appearance with different styles and colors, and + optionally add your logo to the center. Perfect for branding and + marketing campaigns. +

+ +

Use Cases:

+ +
+ +
+

QR Code Customization Options:

+

+ Our QR code generator offers extensive customization options to match + your brand and style preferences: +

+ +
+ +
+

Understanding QR Code Error Correction:

+

+ QR codes include built-in error correction that allows them to be read + even when partially damaged or obscured. This feature is particularly + important when adding logos: +

+ +
+ +
+

QR Code Best Practices:

+

+ Follow these guidelines to create effective and scannable QR codes: +

+ +
+ +
+

Working with QR Codes in JavaScript:

+

+ You can generate and work with QR codes programmatically using + JavaScript libraries. Here's an example using a popular QR code + library: +

+ {qrCodeExample} +

+ This code demonstrates basic QR code generation. For advanced features + like custom styling and logos, consider using specialized libraries + like qr-code-styling or similar tools. +

+
+ +
+

Meet Jam: The Ultimate Tool for Debugging Web Apps

+

+ While this tool helps you create professional QR codes quickly,{" "} + + Jam + {" "} + streamlines your entire development workflow. +

+

+ Jam is{" "} + + the browser extension + {" "} + helping over 140,000 users debug faster. It captures console logs, + network requests, and more with just one click. Perfect for testing QR + code landing pages and debugging web applications. +

+
+ +
+ +
+ +
+

FAQs:

+ +
+
+ ); +} + +const qrCodeExample = `// Basic QR code generation example +import QRCode from 'qrcode'; + +// Generate QR code as data URL +const generateQRCode = async (text) => { + try { + const dataURL = await QRCode.toDataURL(text, { + width: 300, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF' + } + }); + return dataURL; + } catch (error) { + console.error('Error generating QR code:', error); + } +}; + +// Usage +generateQRCode('https://example.com') + .then(dataURL => { + // Use the data URL to display or download the QR code + console.log('QR Code generated:', dataURL); + });`; diff --git a/components/utils/qr-code-generator.utils.test.ts b/components/utils/qr-code-generator.utils.test.ts new file mode 100644 index 0000000..5699520 --- /dev/null +++ b/components/utils/qr-code-generator.utils.test.ts @@ -0,0 +1,264 @@ +import { + QRCodeGenerator, + createQRCode, + validateQRCodeText, + validateImageFile, + imageToBase64, + DEFAULT_QR_OPTIONS, + getErrorCorrectionLevels, + getDotsTypeOptions, + getCornerSquareTypeOptions, + getCornerDotTypeOptions, +} from "./qr-code-generator.utils"; + +// Mock qr-code-styling +const mockQRCodeStyling = { + append: jest.fn(), + update: jest.fn(), + download: jest.fn(), + getRawData: jest.fn(), +}; + +jest.mock("qr-code-styling", () => { + return jest.fn().mockImplementation(() => mockQRCodeStyling); +}); + +// Mock DOM methods +Object.defineProperty(window, 'Image', { + writable: true, + value: class Image { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + src = ''; + + constructor() { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + } + } +}); + +describe("QR Code Generator Utils", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("validateQRCodeText", () => { + it("should return true for valid text", () => { + expect(validateQRCodeText("Hello World")).toBe(true); + expect(validateQRCodeText("https://example.com")).toBe(true); + expect(validateQRCodeText("12345")).toBe(true); + }); + + it("should return false for empty or whitespace-only text", () => { + expect(validateQRCodeText("")).toBe(false); + expect(validateQRCodeText(" ")).toBe(false); + expect(validateQRCodeText("\t\n")).toBe(false); + }); + }); + + describe("validateImageFile", () => { + it("should return true for valid image files", () => { + const validFile1 = new File(["test"], "test.png", { type: "image/png" }); + const validFile2 = new File(["test"], "test.jpg", { type: "image/jpeg" }); + const validFile3 = new File(["test"], "test.gif", { type: "image/gif" }); + + expect(validateImageFile(validFile1)).toBe(true); + expect(validateImageFile(validFile2)).toBe(true); + expect(validateImageFile(validFile3)).toBe(true); + }); + + it("should return false for invalid file types", () => { + const invalidFile = new File(["test"], "test.txt", { + type: "text/plain", + }); + expect(validateImageFile(invalidFile)).toBe(false); + }); + + it("should return false for files that are too large", () => { + const largeFile = new File( + [new ArrayBuffer(6 * 1024 * 1024)], + "large.png", + { + type: "image/png", + } + ); + expect(validateImageFile(largeFile)).toBe(false); + }); + }); + + describe("imageToBase64", () => { + it("should convert file to base64", async () => { + const file = new File(["test content"], "test.png", { + type: "image/png", + }); + + // Mock FileReader + const mockFileReader = { + readAsDataURL: jest.fn(), + result: "data:image/png;base64,dGVzdCBjb250ZW50", + onload: null as unknown, + onerror: null as unknown, + }; + + (global as unknown).FileReader = jest.fn(() => mockFileReader); + + const promise = imageToBase64(file); + + // Trigger the onload event + mockFileReader.onload(); + + const result = await promise; + expect(result).toBe("data:image/png;base64,dGVzdCBjb250ZW50"); + expect(mockFileReader.readAsDataURL).toHaveBeenCalledWith(file); + }); + }); + + describe("createQRCode", () => { + it("should create a QRCodeGenerator instance with default options", () => { + const qrCode = createQRCode(); + expect(qrCode).toBeInstanceOf(QRCodeGenerator); + }); + + it("should create a QRCodeGenerator instance with custom options", () => { + const options = { + text: "Test QR Code", + width: 400, + height: 400, + }; + const qrCode = createQRCode(options); + expect(qrCode).toBeInstanceOf(QRCodeGenerator); + }); + }); + + describe("QRCodeGenerator", () => { + let qrGenerator: QRCodeGenerator; + let mockElement: HTMLElement; + + beforeEach(() => { + jest.clearAllMocks(); + qrGenerator = new QRCodeGenerator(); + mockElement = document.createElement("div"); + }); + + it("should initialize with default options", () => { + expect(qrGenerator).toBeInstanceOf(QRCodeGenerator); + }); + + it("should update QR code options", () => { + qrGenerator.update({ + text: "Updated text", + width: 500, + }); + + // The update method just updates internal options + expect(qrGenerator).toBeInstanceOf(QRCodeGenerator); + }); + + it("should append to DOM element", async () => { + qrGenerator.update({ text: "test" }); // Add text so QR code is generated + await qrGenerator.append(mockElement); + + expect(mockQRCodeStyling.append).toHaveBeenCalledWith(mockElement); + }); + + it("should download QR code", async () => { + qrGenerator.update({ text: "test" }); + await qrGenerator.download("png"); + + expect(mockQRCodeStyling.download).toHaveBeenCalledWith({ + name: "qr-code", + extension: "png", + }); + }); + + it("should get raw data for PNG format", async () => { + mockQRCodeStyling.getRawData.mockResolvedValue( + new Blob(["test"], { type: "image/png" }) + ); + + qrGenerator.update({ text: "test" }); + const result = await qrGenerator.getRawData("png"); + + expect(mockQRCodeStyling.getRawData).toHaveBeenCalledWith("png"); + expect(result).toBeInstanceOf(Blob); + }); + + it("should get raw data for SVG format", async () => { + mockQRCodeStyling.getRawData.mockResolvedValue("test"); + + qrGenerator.update({ text: "test" }); + const result = await qrGenerator.getRawData("svg"); + + expect(mockQRCodeStyling.getRawData).toHaveBeenCalledWith("svg"); + expect(result).toBe("test"); + }); + + it("should handle errors gracefully", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + + qrGenerator.update({ text: "" }); // Empty text + await qrGenerator.append(mockElement); + + // Should not throw, just handle gracefully + expect(consoleSpy).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); + + describe("Option getters", () => { + it("should return error correction levels", () => { + const levels = getErrorCorrectionLevels(); + expect(levels).toHaveLength(4); + expect(levels[0]).toEqual({ + value: "L", + label: "Low (~7%)", + description: "Smallest QR code", + }); + }); + + it("should return dots type options", () => { + const types = getDotsTypeOptions(); + expect(types).toHaveLength(6); + expect(types[0]).toEqual({ + value: "square", + label: "Square", + }); + }); + + it("should return corner square type options", () => { + const types = getCornerSquareTypeOptions(); + expect(types).toHaveLength(3); + expect(types[0]).toEqual({ + value: "square", + label: "Square", + }); + }); + + it("should return corner dot type options", () => { + const types = getCornerDotTypeOptions(); + expect(types).toHaveLength(2); + expect(types[0]).toEqual({ + value: "square", + label: "Square", + }); + }); + }); + + describe("DEFAULT_QR_OPTIONS", () => { + it("should have correct default values", () => { + expect(DEFAULT_QR_OPTIONS.text).toBe(""); + expect(DEFAULT_QR_OPTIONS.width).toBe(300); + expect(DEFAULT_QR_OPTIONS.height).toBe(300); + expect(DEFAULT_QR_OPTIONS.format).toBe("png"); + expect(DEFAULT_QR_OPTIONS.errorCorrectionLevel).toBe("M"); + expect(DEFAULT_QR_OPTIONS.dotsOptions.color).toBe("#000000"); + expect(DEFAULT_QR_OPTIONS.dotsOptions.type).toBe("square"); + expect(DEFAULT_QR_OPTIONS.backgroundOptions.color).toBe("#ffffff"); + }); + }); +}); diff --git a/components/utils/qr-code-generator.utils.ts b/components/utils/qr-code-generator.utils.ts new file mode 100644 index 0000000..c2e9151 --- /dev/null +++ b/components/utils/qr-code-generator.utils.ts @@ -0,0 +1,323 @@ +// Dynamic import to handle SSR issues +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let QRCodeStyling: any = null; + +if (typeof window !== "undefined") { + import("qr-code-styling").then((QRCodeStylingModule) => { + QRCodeStyling = QRCodeStylingModule.default; + }); +} + +export type QRCodeFormat = "png" | "svg" | "jpeg" | "webp"; + +export type QRCodeCornerSquareType = "square" | "extra-rounded" | "dot"; +export type QRCodeCornerDotType = "square" | "dot"; +export type QRCodeDotsType = + | "square" + | "rounded" + | "dots" + | "classy" + | "classy-rounded" + | "extra-rounded"; + +export type QRCodeErrorCorrectionLevel = "L" | "M" | "Q" | "H"; + +export interface QRCodeOptions { + text: string; + width: number; + height: number; + format: QRCodeFormat; + errorCorrectionLevel: QRCodeErrorCorrectionLevel; + dotsOptions: { + color: string; + type: QRCodeDotsType; + }; + backgroundOptions: { + color: string; + }; + cornersSquareOptions?: { + color: string; + type: QRCodeCornerSquareType; + }; + cornersDotOptions?: { + color: string; + type: QRCodeCornerDotType; + }; + image?: string; // Base64 string or URL + imageOptions?: { + hideBackgroundDots: boolean; + imageSize: number; + margin: number; + crossOrigin: string; + }; +} + +export const DEFAULT_QR_OPTIONS: QRCodeOptions = { + text: "", + width: 300, + height: 300, + format: "png", + errorCorrectionLevel: "M", + dotsOptions: { + color: "#000000", + type: "square", + }, + backgroundOptions: { + color: "#ffffff", + }, + cornersSquareOptions: { + color: "#000000", + type: "square", + }, + cornersDotOptions: { + color: "#000000", + type: "square", + }, + imageOptions: { + hideBackgroundDots: true, + imageSize: 0.4, + margin: 8, + crossOrigin: "anonymous", + }, +}; + +export class QRCodeGenerator { + private options: QRCodeOptions; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private qrCodeStyling: any = null; + + constructor(options: Partial = {}) { + this.options = { ...DEFAULT_QR_OPTIONS, ...options }; + } + + private async initializeQRCodeStyling(): Promise { + if (!QRCodeStyling && typeof window !== "undefined") { + const QRCodeStylingModule = await import("qr-code-styling"); + QRCodeStyling = QRCodeStylingModule.default; + } + + if (QRCodeStyling && !this.qrCodeStyling) { + this.qrCodeStyling = new QRCodeStyling({ + width: this.options.width, + height: this.options.height, + type: "svg", + data: this.options.text || "https://jam.dev", + image: this.options.image, + dotsOptions: { + color: this.options.dotsOptions.color, + type: this.options.dotsOptions.type, + }, + backgroundOptions: { + color: this.options.backgroundOptions.color, + }, + cornersSquareOptions: { + color: this.options.cornersSquareOptions?.color, + type: this.options.cornersSquareOptions?.type, + }, + cornersDotOptions: { + color: this.options.cornersDotOptions?.color, + type: this.options.cornersDotOptions?.type, + }, + imageOptions: { + hideBackgroundDots: this.options.imageOptions?.hideBackgroundDots, + imageSize: this.options.imageOptions?.imageSize, + margin: this.options.imageOptions?.margin, + crossOrigin: this.options.imageOptions?.crossOrigin, + }, + qrOptions: { + errorCorrectionLevel: this.options.errorCorrectionLevel, + }, + }); + } + } + + update(newOptions: Partial): void { + this.options = { ...this.options, ...newOptions }; + + if (this.qrCodeStyling) { + this.qrCodeStyling.update({ + width: this.options.width, + height: this.options.height, + data: this.options.text || "https://jam.dev", + image: this.options.image, + dotsOptions: { + color: this.options.dotsOptions.color, + type: this.options.dotsOptions.type, + }, + backgroundOptions: { + color: this.options.backgroundOptions.color, + }, + cornersSquareOptions: { + color: this.options.cornersSquareOptions?.color, + type: this.options.cornersSquareOptions?.type, + }, + cornersDotOptions: { + color: this.options.cornersDotOptions?.color, + type: this.options.cornersDotOptions?.type, + }, + imageOptions: { + hideBackgroundDots: this.options.imageOptions?.hideBackgroundDots, + imageSize: this.options.imageOptions?.imageSize, + margin: this.options.imageOptions?.margin, + crossOrigin: this.options.imageOptions?.crossOrigin, + }, + qrOptions: { + errorCorrectionLevel: this.options.errorCorrectionLevel, + }, + }); + } + } + + async append(element: HTMLElement): Promise { + try { + await this.initializeQRCodeStyling(); + + if (this.qrCodeStyling && element) { + // Clear previous content + element.innerHTML = ""; + + if (!this.options.text) { + return; + } + + // Append the QR code to the element + this.qrCodeStyling.append(element); + } + } catch (error) { + console.error("Error generating QR code:", error); + } + } + + async download(format: QRCodeFormat = "png"): Promise { + try { + await this.initializeQRCodeStyling(); + + if (this.qrCodeStyling) { + await this.qrCodeStyling.download({ + name: "qr-code", + extension: format, + }); + } + } catch (error) { + console.error("Error downloading QR code:", error); + } + } + + async getRawData(format: QRCodeFormat = "png"): Promise { + try { + await this.initializeQRCodeStyling(); + + if (!this.qrCodeStyling) { + throw new Error("QR code styling not initialized"); + } + + if (format === "svg") { + return await this.qrCodeStyling.getRawData("svg"); + } else { + return await this.qrCodeStyling.getRawData("png"); + } + } catch (error) { + throw new Error(`Failed to generate QR code data: ${error}`); + } + } + + async getDataURL(format: QRCodeFormat = "png"): Promise { + const rawData = await this.getRawData(format); + + if (typeof rawData === "string") { + return rawData; // SVG returns string + } + + // For other formats, convert Blob to data URL + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(rawData); + }); + } +} + +export const createQRCode = ( + options: Partial = {} +): QRCodeGenerator => { + return new QRCodeGenerator(options); +}; + +export const validateQRCodeText = (text: string): boolean => { + return text.trim().length > 0; +}; + +export const getQRCodeSizeInBytes = async ( + qrCode: QRCodeGenerator, + format: QRCodeFormat = "png" +): Promise => { + const rawData = await qrCode.getRawData(format); + + if (typeof rawData === "string") { + return new Blob([rawData]).size; + } + + return rawData.size; +}; + +export const imageToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + resolve(result); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +}; + +export const validateImageFile = (file: File): boolean => { + const validTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", + "image/svg+xml", + ]; + const maxSize = 5 * 1024 * 1024; // 5MB + + return validTypes.includes(file.type) && file.size <= maxSize; +}; + +export const getErrorCorrectionLevels = () => [ + { value: "L" as const, label: "Low (~7%)", description: "Smallest QR code" }, + { value: "M" as const, label: "Medium (~15%)", description: "Balanced" }, + { + value: "Q" as const, + label: "Quartile (~25%)", + description: "Good for noisy environments", + }, + { + value: "H" as const, + label: "High (~30%)", + description: "Best for logos and customization", + }, +]; + +export const getDotsTypeOptions = () => [ + { value: "square" as const, label: "Square" }, + { value: "rounded" as const, label: "Rounded" }, + { value: "dots" as const, label: "Dots" }, + { value: "classy" as const, label: "Classy" }, + { value: "classy-rounded" as const, label: "Classy Rounded" }, + { value: "extra-rounded" as const, label: "Extra Rounded" }, +]; + +export const getCornerSquareTypeOptions = () => [ + { value: "square" as const, label: "Square" }, + { value: "extra-rounded" as const, label: "Extra Rounded" }, + { value: "dot" as const, label: "Dot" }, +]; + +export const getCornerDotTypeOptions = () => [ + { value: "square" as const, label: "Square" }, + { value: "dot" as const, label: "Dot" }, +]; diff --git a/components/utils/tools-list.ts b/components/utils/tools-list.ts index ef41923..80e43db 100644 --- a/components/utils/tools-list.ts +++ b/components/utils/tools-list.ts @@ -149,4 +149,10 @@ export const tools = [ "Minify SQL by removing comments, extra spaces, and formatting for cleaner, optimized queries.", link: "/utilities/sql-minifier", }, + { + title: "QR Code Generator", + description: + "Create customizable QR codes with logos, different styles (dots, squares, rounded), and custom colors. Perfect for marketing materials and business cards.", + link: "/utilities/qr-code-generator", + }, ]; diff --git a/package-lock.json b/package-lock.json index 91b3505..c42c5a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", + "@types/qrcode": "^1.5.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -26,6 +27,8 @@ "next": "14.2.4", "next-themes": "^0.3.0", "papaparse": "^5.4.1", + "qr-code-styling": "^1.9.2", + "qrcode": "^1.5.4", "react": "^18", "react-dom": "^18", "react-syntax-highlighter": "^15.5.0", @@ -40,6 +43,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^20", "@types/papaparse": "^5.3.14", + "@types/qrcode-generator": "^0.0.16", "@types/react": "^18", "@types/react-dom": "^18", "@types/react-syntax-highlighter": "^15.5.13", @@ -3488,7 +3492,6 @@ "version": "20.14.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz", "integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==", - "devOptional": true, "dependencies": { "undici-types": "~5.26.4" } @@ -3508,6 +3511,22 @@ "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", "devOptional": true }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qrcode-generator": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@types/qrcode-generator/-/qrcode-generator-0.0.16.tgz", + "integrity": "sha512-i+wPpeHH64qJHUIz51+tPq5FRkgMxE9/uhDE5eSlj3qujIjHm3U1wHfy8nMD/m1VKaXHO/uDjHamPVz2Fr4IPA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", @@ -4681,7 +4700,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "engines": { "node": ">=6" } @@ -5366,6 +5384,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", @@ -5551,6 +5578,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -6877,7 +6910,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -9833,7 +9865,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "engines": { "node": ">=6" } @@ -9906,7 +9937,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -10052,6 +10082,15 @@ "node": ">=8" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -10336,6 +10375,179 @@ } ] }, + "node_modules/qr-code-styling": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/qr-code-styling/-/qr-code-styling-1.9.2.tgz", + "integrity": "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==", + "license": "MIT", + "dependencies": { + "qrcode-generator": "^1.4.4" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode-generator": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.5.2.tgz", + "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -10597,7 +10809,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -10611,6 +10822,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -10907,8 +11124,7 @@ "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, "node_modules/set-function-length": { "version": "1.2.2", @@ -12087,8 +12303,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "devOptional": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unicorn-magic": { "version": "0.1.0", @@ -12485,6 +12700,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", diff --git a/package.json b/package.json index 0f71bea..e013c01 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", + "@types/qrcode": "^1.5.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -30,6 +31,8 @@ "next": "14.2.4", "next-themes": "^0.3.0", "papaparse": "^5.4.1", + "qr-code-styling": "^1.9.2", + "qrcode": "^1.5.4", "react": "^18", "react-dom": "^18", "react-syntax-highlighter": "^15.5.0", @@ -44,6 +47,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^20", "@types/papaparse": "^5.3.14", + "@types/qrcode-generator": "^0.0.16", "@types/react": "^18", "@types/react-dom": "^18", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/pages/utilities/qr-code-generator.tsx b/pages/utilities/qr-code-generator.tsx new file mode 100644 index 0000000..84f335d --- /dev/null +++ b/pages/utilities/qr-code-generator.tsx @@ -0,0 +1,596 @@ +import { useCallback, useState, useEffect, useRef } from "react"; +import PageHeader from "@/components/PageHeader"; +import { Card } from "@/components/ds/CardComponent"; +import { Button } from "@/components/ds/ButtonComponent"; +import { Label } from "@/components/ds/LabelComponent"; +import Header from "@/components/Header"; +import { CMDK } from "@/components/CMDK"; +import CallToActionGrid from "@/components/CallToActionGrid"; +import Meta from "@/components/Meta"; +import { Input } from "@/components/ds/InputComponent"; +import { Textarea } from "@/components/ds/TextareaComponent"; +import { Combobox } from "@/components/ds/ComboboxComponent"; +import { ImageUploadComponent } from "@/components/ds/ImageUploadComponent"; +import { DownloadIcon, RefreshCwIcon } from "lucide-react"; +import GitHubContribution from "@/components/GitHubContribution"; +import QrCodeSEO from "@/components/seo/QrCodeSEO"; + +// Custom hook for debouncing values +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} +import { + QRCodeGenerator, + QRCodeFormat, + QRCodeErrorCorrectionLevel, + QRCodeDotsType, + QRCodeCornerSquareType, + QRCodeCornerDotType, + createQRCode, + validateQRCodeText, + validateImageFile, + imageToBase64, + getErrorCorrectionLevels, + getDotsTypeOptions, + getCornerSquareTypeOptions, + getCornerDotTypeOptions, +} from "@/components/utils/qr-code-generator.utils"; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +interface FormatOption { + value: QRCodeFormat; + label: string; +} + +const formatOptions: FormatOption[] = [ + { value: "png", label: "PNG" }, + { value: "svg", label: "SVG" }, + { value: "jpeg", label: "JPEG" }, + { value: "webp", label: "WebP" }, +]; + +export default function QrCodeGenerator() { + const [text, setText] = useState(""); + const [qrCodeInstance, setQrCodeInstance] = useState( + null + ); + const [format, setFormat] = useState("png"); + const [sizeInput, setSizeInput] = useState("300"); // Display value for input + const [size, setSize] = useState(300); // Actual size used for QR generation + const [errorCorrectionLevel, setErrorCorrectionLevel] = + useState("M"); + const [dotsType, setDotsType] = useState("square"); + const [dotsColor, setDotsColor] = useState("#000000"); + const [backgroundColor, setBackgroundColor] = useState("#ffffff"); + const [cornerSquareType, setCornerSquareType] = + useState("square"); + const [cornerSquareColor, setCornerSquareColor] = useState("#000000"); + const [cornerDotType, setCornerDotType] = + useState("square"); + const [cornerDotColor, setCornerDotColor] = useState("#000000"); + const [logoFile, setLogoFile] = useState(null); + const [logoBase64, setLogoBase64] = useState(""); + const [logoSize, setLogoSize] = useState(0.4); + const [logoMargin, setLogoMargin] = useState(8); + const [hideBackgroundDots, setHideBackgroundDots] = useState(true); + const [isGenerating, setIsGenerating] = useState(false); + + const qrContainerRef = useRef(null); + + // Debounce the size input to avoid constant QR regeneration while typing + const debouncedSizeInput = useDebounce(sizeInput, 500); + + // Update actual size when debounced input changes + useEffect(() => { + if (debouncedSizeInput.trim() === "") { + // If input is empty, use default size + setSize(300); + return; + } + + const newSize = parseInt(debouncedSizeInput); + if (!isNaN(newSize)) { + const clampedSize = Math.max(100, Math.min(800, newSize)); + setSize(clampedSize); + } + }, [debouncedSizeInput]); + + // Initialize QR code instance + useEffect(() => { + const qr = createQRCode({ + text: text || "https://jam.dev", + width: size, + height: size, + format, + errorCorrectionLevel, + dotsOptions: { + color: dotsColor, + type: dotsType, + }, + backgroundOptions: { + color: backgroundColor, + }, + cornersSquareOptions: { + color: cornerSquareColor, + type: cornerSquareType, + }, + cornersDotOptions: { + color: cornerDotColor, + type: cornerDotType, + }, + image: logoBase64, + imageOptions: { + hideBackgroundDots, + imageSize: logoSize, + margin: logoMargin, + crossOrigin: "anonymous", + }, + }); + + setQrCodeInstance(qr); + + return () => { + // Cleanup if needed + }; + }, [ + text, + size, + format, + errorCorrectionLevel, + dotsType, + dotsColor, + backgroundColor, + cornerSquareType, + cornerSquareColor, + cornerDotType, + cornerDotColor, + logoBase64, + logoSize, + logoMargin, + hideBackgroundDots, + ]); + + // Update QR code when options change + useEffect(() => { + const updateQRCode = async () => { + if (qrCodeInstance && qrContainerRef.current) { + setIsGenerating(true); + + try { + qrCodeInstance.update({ + text: text || "https://jam.dev", + width: size, + height: size, + errorCorrectionLevel, + dotsOptions: { + color: dotsColor, + type: dotsType, + }, + backgroundOptions: { + color: backgroundColor, + }, + cornersSquareOptions: { + color: cornerSquareColor, + type: cornerSquareType, + }, + cornersDotOptions: { + color: cornerDotColor, + type: cornerDotType, + }, + image: logoBase64, + imageOptions: { + hideBackgroundDots, + imageSize: logoSize, + margin: logoMargin, + crossOrigin: "anonymous", + }, + }); + + // Re-render the QR code + await qrCodeInstance.append(qrContainerRef.current); + } catch (error) { + console.error("Error updating QR code:", error); + } + + setTimeout(() => setIsGenerating(false), 300); + } + }; + + updateQRCode(); + }, [ + qrCodeInstance, + text, + size, + errorCorrectionLevel, + dotsType, + dotsColor, + backgroundColor, + cornerSquareType, + cornerSquareColor, + cornerDotType, + cornerDotColor, + logoBase64, + logoSize, + logoMargin, + hideBackgroundDots, + ]); + + const handleTextChange = useCallback( + (e: React.ChangeEvent) => { + setText(e.target.value); + }, + [] + ); + + const handleFormatSelect = useCallback((value: string) => { + setFormat(value as QRCodeFormat); + }, []); + + const handleErrorCorrectionSelect = useCallback((value: string) => { + setErrorCorrectionLevel(value as QRCodeErrorCorrectionLevel); + }, []); + + const handleDotsTypeSelect = useCallback((value: string) => { + setDotsType(value as QRCodeDotsType); + }, []); + + const handleCornerSquareTypeSelect = useCallback((value: string) => { + setCornerSquareType(value as QRCodeCornerSquareType); + }, []); + + const handleCornerDotTypeSelect = useCallback((value: string) => { + setCornerDotType(value as QRCodeCornerDotType); + }, []); + + const handleLogoSelect = useCallback(async (file: File) => { + if (validateImageFile(file)) { + setLogoFile(file); + try { + const base64 = await imageToBase64(file); + setLogoBase64(base64); + } catch (error) { + console.error("Error converting image to base64:", error); + } + } + }, []); + + const handleDownload = useCallback(async () => { + if (qrCodeInstance) { + try { + await qrCodeInstance.download(format); + } catch (error) { + console.error("Error downloading QR code:", error); + } + } + }, [qrCodeInstance, format]); + + const handleRemoveLogo = useCallback(() => { + setLogoFile(null); + setLogoBase64(""); + }, []); + + const handleSizeChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setSizeInput(value); // Update display value immediately + }, + [] + ); + + const handleLogoSizeChange = useCallback( + (e: React.ChangeEvent) => { + const newSize = Math.max( + 0.1, + Math.min(0.8, parseFloat(e.target.value) || 0.4) + ); + setLogoSize(newSize); + }, + [] + ); + + const handleLogoMarginChange = useCallback( + (e: React.ChangeEvent) => { + const newMargin = Math.max( + 0, + Math.min(20, parseInt(e.target.value) || 8) + ); + setLogoMargin(newMargin); + }, + [] + ); + + const isValidText = validateQRCodeText(text); + + return ( +
+ +
+ + +
+ +
+ +
+
+ {/* Controls */} + +
+ {/* Text Input */} +
+ +