@tomo-inc/tomo-ui 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/index.html +12 -0
  2. package/package.json +45 -0
  3. package/postcss.config.cjs +6 -0
  4. package/project.json +7 -0
  5. package/src/App.tsx +23 -0
  6. package/src/components/button/index.tsx +2 -0
  7. package/src/components/card/index.tsx +2 -0
  8. package/src/components/chip/index.tsx +2 -0
  9. package/src/components/image/index.ts +2 -0
  10. package/src/components/index.ts +14 -0
  11. package/src/components/input/index.tsx +37 -0
  12. package/src/components/link/index.ts +2 -0
  13. package/src/components/mfaTypeChoose/index.tsx +114 -0
  14. package/src/components/modal/index.tsx +13 -0
  15. package/src/components/modal/modal-body.tsx +10 -0
  16. package/src/components/modal/modal-content.tsx +17 -0
  17. package/src/components/modal/modal-context.tsx +12 -0
  18. package/src/components/modal/modal-footer.tsx +12 -0
  19. package/src/components/modal/modal-header.tsx +28 -0
  20. package/src/components/modal/modal.tsx +152 -0
  21. package/src/components/modal/use-disclosure.ts +46 -0
  22. package/src/components/qr/index.tsx +94 -0
  23. package/src/components/select/index.tsx +2 -0
  24. package/src/components/skeleton/index.tsx +2 -0
  25. package/src/components/spinner/index.tsx +2 -0
  26. package/src/components/tabs/index.tsx +2 -0
  27. package/src/components/toast/index.ts +2 -0
  28. package/src/icons/back-icon.tsx +12 -0
  29. package/src/icons/close-icon.tsx +23 -0
  30. package/src/icons/copy-icon.tsx +22 -0
  31. package/src/icons/down-icon.tsx +15 -0
  32. package/src/icons/index.ts +13 -0
  33. package/src/icons/more-icon.tsx +12 -0
  34. package/src/icons/mydoge-icon.tsx +44 -0
  35. package/src/icons/ok-icon.tsx +20 -0
  36. package/src/icons/power-icon.tsx +15 -0
  37. package/src/icons/right-arrow-icon.tsx +15 -0
  38. package/src/icons/search-icon.tsx +19 -0
  39. package/src/icons/search-outlined-icon.tsx +12 -0
  40. package/src/icons/tomo-icon.tsx +30 -0
  41. package/src/icons/twitter-icon.tsx +12 -0
  42. package/src/index.ts +4 -0
  43. package/src/main.tsx +9 -0
  44. package/src/style.css +4 -0
  45. package/src/tailwind/plugin.ts +177 -0
  46. package/src/tailwind/tailwind.css +5 -0
  47. package/src/theme-provider.tsx +19 -0
  48. package/tsconfig.json +43 -0
  49. package/vite.config.ts +10 -0
package/index.html ADDED
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Tomo UI</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="./src/main.tsx"></script>
11
+ </body>
12
+ </html>
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@tomo-inc/tomo-ui",
3
+ "version": "0.0.3",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "module": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "import": "./src/index.ts",
12
+ "require": "./src/index.ts",
13
+ "default": "./src/index.ts"
14
+ },
15
+ "./tailwind/tailwind.css": "./src/tailwind/tailwind.css"
16
+ },
17
+ "private": false,
18
+ "dependencies": {
19
+ "@heroui/react": "2.8.5",
20
+ "@heroui/toast": "^2.0.17",
21
+ "framer-motion": "^11.5.6",
22
+ "next-themes": "^0.4.6",
23
+ "qr-code-styling": "^1.9.2"
24
+ },
25
+ "peerDependencies": {
26
+ "react": ">=18",
27
+ "react-dom": ">=18"
28
+ },
29
+ "devDependencies": {
30
+ "@tailwindcss/cli": "^4.1.13",
31
+ "@tailwindcss/postcss": "^4.1.13",
32
+ "@types/react": "^18.3.3",
33
+ "@types/react-dom": "^18.3.0",
34
+ "@vitejs/plugin-react": "^5.1.1",
35
+ "autoprefixer": "^10.4.21",
36
+ "postcss": "^8.5.6",
37
+ "tailwindcss": "^4.1.17",
38
+ "tsup": "^8.3.5",
39
+ "typescript": "^5.7.2",
40
+ "vite": "^7.2.4"
41
+ },
42
+ "scripts": {
43
+ "dev": "vite"
44
+ }
45
+ }
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ autoprefixer: {},
5
+ },
6
+ };
package/project.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "tomo-ui",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "sourceRoot": "uikits/tomo-ui/src",
5
+ "projectType": "library",
6
+ "tags": ["scope:uikit", "type:tomo-uikit"]
7
+ }
package/src/App.tsx ADDED
@@ -0,0 +1,23 @@
1
+ import { QRCodeComponent } from "./components";
2
+ import { Button } from "./components/button";
3
+ import { Input, NumberInput } from "./components/input";
4
+ import "./style.css";
5
+ import { TomoUIProvider } from "./theme-provider";
6
+
7
+ function App() {
8
+ return (
9
+ <TomoUIProvider defaultTheme="doge_light" forcedTheme="doge_light">
10
+ 123123
11
+ <Input label="Email" type="email" />
12
+ <NumberInput label="Number" placeholder="Enter your number" type="number" />
13
+ <Button color="primary">Click me</Button>
14
+ <QRCodeComponent
15
+ content="https://tomo.inc"
16
+ size={280}
17
+ logo="https://faucet.testnet.dogeos.com/images/h4.avif"
18
+ logoBackground="#1C1917"
19
+ />
20
+ </TomoUIProvider>
21
+ );
22
+ }
23
+ export default App;
@@ -0,0 +1,2 @@
1
+ export { Button, ButtonGroup } from "@heroui/react";
2
+ export type { ButtonGroupProps, ButtonProps } from "@heroui/react";
@@ -0,0 +1,2 @@
1
+ export { Card, CardBody, CardFooter, CardHeader } from "@heroui/react";
2
+ export type { CardProps } from "@heroui/react";
@@ -0,0 +1,2 @@
1
+ export { Chip } from "@heroui/react";
2
+ export type { ChipProps } from "@heroui/react";
@@ -0,0 +1,2 @@
1
+ export { Image } from "@heroui/react";
2
+ export type { ImageProps } from "@heroui/react";
@@ -0,0 +1,14 @@
1
+ export * from "./button";
2
+ export * from "./card";
3
+ export * from "./chip";
4
+ export * from "./image";
5
+ export * from "./input";
6
+ export * from "./link";
7
+ export * from "./mfaTypeChoose";
8
+ export * from "./modal";
9
+ export * from "./qr";
10
+ export * from "./select";
11
+ export * from "./skeleton";
12
+ export * from "./spinner";
13
+ export * from "./tabs";
14
+ export * from "./toast";
@@ -0,0 +1,37 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import {
3
+ DateInput,
4
+ type DateInputProps,
5
+ Input,
6
+ InputOtp,
7
+ type InputOtpProps,
8
+ type InputProps,
9
+ NumberInput,
10
+ type NumberInputProps,
11
+ Textarea,
12
+ type TextAreaProps,
13
+ TimeInput,
14
+ type TimeInputProps,
15
+ } from "@heroui/react";
16
+
17
+ Input.displayName = "Input";
18
+ NumberInput.displayName = "NumberInput";
19
+ (DateInput as any).displayName = "DateInput";
20
+ Textarea.displayName = "Textarea";
21
+ (TimeInput as any).displayName = "TimeInput";
22
+ InputOtp.displayName = "InputOtp";
23
+
24
+ export {
25
+ DateInput,
26
+ Input,
27
+ InputOtp,
28
+ NumberInput,
29
+ Textarea,
30
+ TimeInput,
31
+ type DateInputProps,
32
+ type InputOtpProps,
33
+ type InputProps,
34
+ type NumberInputProps,
35
+ type TextAreaProps,
36
+ type TimeInputProps,
37
+ };
@@ -0,0 +1,2 @@
1
+ export { Link } from "@heroui/react";
2
+ export type { LinkProps } from "@heroui/react";
@@ -0,0 +1,114 @@
1
+ import { cn, RadioGroup, RadioProps, useRadio, VisuallyHidden } from "@heroui/react";
2
+ import { useEffect, useState } from "react";
3
+
4
+ interface CustomRadioProps extends RadioProps {
5
+ countdown?: number;
6
+ }
7
+
8
+ const CustomRadio = ({ countdown, ...props }: CustomRadioProps) => {
9
+ const {
10
+ Component,
11
+ children,
12
+ isDisabled,
13
+ getBaseProps,
14
+ getWrapperProps,
15
+ getInputProps,
16
+ getLabelProps,
17
+ getLabelWrapperProps,
18
+ getControlProps,
19
+ } = useRadio(props);
20
+
21
+ return (
22
+ <Component
23
+ {...getBaseProps()}
24
+ className={cn(
25
+ "group inline-flex items-center hover:opacity-70 active:opacity-50 justify-between flex-row-reverse tap-highlight-transparent",
26
+ "max-w-full cursor-pointer border-2 border-default rounded-lg gap-4 p-4",
27
+ "data-[selected=true]:border-primary",
28
+ isDisabled && "opacity-50 cursor-not-allowed",
29
+ )}
30
+ >
31
+ <VisuallyHidden>
32
+ <input {...getInputProps()} />
33
+ </VisuallyHidden>
34
+ <span {...getWrapperProps()}>
35
+ <span {...getControlProps()} />
36
+ </span>
37
+ <div {...getLabelWrapperProps()} className="flex items-center justify-between w-full">
38
+ <span {...getLabelProps()} className="text-default-600 text-base font-medium">
39
+ {children}
40
+ </span>
41
+ {countdown !== undefined && countdown > 0 && <CountdownTimer seconds={countdown} />}
42
+ </div>
43
+ </Component>
44
+ );
45
+ };
46
+ export interface MfaTypeOption {
47
+ value: string;
48
+ label: string;
49
+ description?: string;
50
+ disabled?: boolean;
51
+ countdown?: number;
52
+ }
53
+
54
+ export interface MfaTypeChooseProps {
55
+ value: string;
56
+ onValueChange: (value: string) => void;
57
+ options?: MfaTypeOption[];
58
+ }
59
+
60
+ const defaultOptions: MfaTypeOption[] = [
61
+ { value: "fido", label: "Passkey" },
62
+ { value: "totp", label: "Authenticator" },
63
+ { value: "emailOtp", label: "OTP Email" },
64
+ ];
65
+
66
+ const CountdownTimer = ({ seconds }: { seconds: number }) => {
67
+ const [timeLeft, setTimeLeft] = useState(seconds);
68
+
69
+ useEffect(() => {
70
+ setTimeLeft(seconds);
71
+ }, [seconds]);
72
+
73
+ useEffect(() => {
74
+ if (timeLeft <= 0) return;
75
+
76
+ const timer = setInterval(() => {
77
+ setTimeLeft((prev) => {
78
+ if (prev <= 1) return 0;
79
+ return prev - 1;
80
+ });
81
+ }, 1000);
82
+
83
+ return () => clearInterval(timer);
84
+ }, [timeLeft]);
85
+
86
+ const formatTime = (totalSeconds: number) => {
87
+ const hours = Math.floor(totalSeconds / 3600);
88
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
89
+ const secs = totalSeconds % 60;
90
+
91
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
92
+ };
93
+
94
+ return <span className="text-sm font-medium text-default-500">{formatTime(timeLeft)}</span>;
95
+ };
96
+
97
+ export function MfaTypeChoose({ value, onValueChange, options = defaultOptions }: MfaTypeChooseProps) {
98
+ return (
99
+ <RadioGroup value={value} onValueChange={onValueChange} className="w-full">
100
+ {options.map((option) => (
101
+ <CustomRadio
102
+ key={option.value}
103
+ value={option.value}
104
+ description={option.description}
105
+ isDisabled={option.disabled}
106
+ countdown={option.countdown}
107
+ >
108
+ {option.label}
109
+ </CustomRadio>
110
+ ))}
111
+ </RadioGroup>
112
+ );
113
+ }
114
+ export default MfaTypeChoose;
@@ -0,0 +1,13 @@
1
+ export { Modal } from "./modal";
2
+ export { ModalBody } from "./modal-body";
3
+ export { ModalContent } from "./modal-content";
4
+ export { ModalFooter } from "./modal-footer";
5
+ export { ModalHeader } from "./modal-header";
6
+ export { useDisclosure } from "./use-disclosure";
7
+
8
+ export type { ModalProps } from "./modal";
9
+ export type { ModalBodyProps } from "./modal-body";
10
+ export type { ModalContentProps } from "./modal-content";
11
+ export type { ModalFooterProps } from "./modal-footer";
12
+ export type { ModalHeaderProps } from "./modal-header";
13
+ export type { UseDisclosureProps } from "./use-disclosure";
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface ModalBodyProps {
4
+ children: ReactNode;
5
+ className?: string;
6
+ }
7
+
8
+ export function ModalBody({ children, className = "" }: ModalBodyProps) {
9
+ return <div className={`p-4 overflow-y-auto flex-1 ${className}`}>{children}</div>;
10
+ }
@@ -0,0 +1,17 @@
1
+ import type { ReactNode } from "react";
2
+ import { useModalContext } from "./modal-context";
3
+
4
+ export interface ModalContentProps {
5
+ children: ReactNode | ((onClose: () => void) => ReactNode);
6
+ className?: string;
7
+ onClose?: () => void;
8
+ }
9
+
10
+ export function ModalContent({ children, className = "", onClose }: ModalContentProps) {
11
+ const context = useModalContext();
12
+ const handleClose = onClose || context.onClose || (() => {});
13
+
14
+ const content = typeof children === "function" ? children(handleClose) : children;
15
+
16
+ return <div className={`flex flex-col ${className}`}>{content}</div>;
17
+ }
@@ -0,0 +1,12 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ export interface ModalContextValue {
4
+ onClose?: () => void;
5
+ hideCloseButton?: boolean;
6
+ }
7
+
8
+ export const ModalContext = createContext<ModalContextValue>({});
9
+
10
+ export function useModalContext() {
11
+ return useContext(ModalContext);
12
+ }
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface ModalFooterProps {
4
+ children: ReactNode;
5
+ className?: string;
6
+ }
7
+
8
+ export function ModalFooter({ children, className = "" }: ModalFooterProps) {
9
+ return (
10
+ <div className={`flex items-center justify-end gap-2 p-4 border-t border-default-200 ${className}`}>{children}</div>
11
+ );
12
+ }
@@ -0,0 +1,28 @@
1
+ import type { ReactNode } from "react";
2
+ import { CloseIcon } from "../../icons";
3
+ import { Button } from "../button";
4
+ import { useModalContext } from "./modal-context";
5
+
6
+ export interface ModalHeaderProps {
7
+ children: ReactNode;
8
+ className?: string;
9
+ onClose?: () => void;
10
+ hideCloseButton?: boolean;
11
+ }
12
+
13
+ export function ModalHeader({ children, className = "", onClose, hideCloseButton }: ModalHeaderProps) {
14
+ const context = useModalContext();
15
+ const handleClose = onClose || context.onClose;
16
+ const shouldHideCloseButton = hideCloseButton ?? context.hideCloseButton ?? false;
17
+
18
+ return (
19
+ <div className={`flex items-center justify-between p-4 border-b border-default-200 ${className}`}>
20
+ <div className="flex-1">{children}</div>
21
+ {!shouldHideCloseButton && handleClose && (
22
+ <Button isIconOnly size="sm" variant="light" onPress={handleClose} className="ml-2" aria-label="Close">
23
+ <CloseIcon />
24
+ </Button>
25
+ )}
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,152 @@
1
+ import type { ReactNode } from "react";
2
+ import { cloneElement, isValidElement, useEffect, useRef } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { ModalBody } from "./modal-body";
5
+ import { ModalContent } from "./modal-content";
6
+ import { ModalContext } from "./modal-context";
7
+ import { ModalFooter } from "./modal-footer";
8
+ import { ModalHeader } from "./modal-header";
9
+
10
+ export type ModalSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "full";
11
+
12
+ export type ModalRadius = "none" | "sm" | "md" | "lg" | "full";
13
+
14
+ export type ScrollBehavior = "inside" | "outside";
15
+
16
+ export interface ModalProps {
17
+ isOpen?: boolean;
18
+ onOpenChange?: (isOpen: boolean) => void;
19
+ children: ReactNode;
20
+ size?: ModalSize;
21
+ radius?: ModalRadius;
22
+ isDismissable?: boolean;
23
+ isKeyboardDismissDisabled?: boolean;
24
+ hideCloseButton?: boolean;
25
+ scrollBehavior?: ScrollBehavior;
26
+ className?: string;
27
+ classNames?: {
28
+ base?: string;
29
+ backdrop?: string;
30
+ wrapper?: string;
31
+ };
32
+ }
33
+
34
+ const sizeClasses: Record<ModalSize, string> = {
35
+ xs: "max-w-xs",
36
+ sm: "max-w-sm",
37
+ md: "max-w-md",
38
+ lg: "max-w-lg",
39
+ xl: "max-w-xl",
40
+ "2xl": "max-w-2xl",
41
+ "3xl": "max-w-3xl",
42
+ "4xl": "max-w-4xl",
43
+ "5xl": "max-w-5xl",
44
+ full: "max-w-full",
45
+ };
46
+
47
+ const radiusClasses: Record<ModalRadius, string> = {
48
+ none: "rounded-none",
49
+ sm: "rounded-sm",
50
+ md: "rounded-md",
51
+ lg: "rounded-lg",
52
+ full: "rounded-full",
53
+ };
54
+
55
+ export function Modal({
56
+ isOpen = false,
57
+ onOpenChange,
58
+ children,
59
+ size = "md",
60
+ radius = "md",
61
+ isDismissable = true,
62
+ isKeyboardDismissDisabled = false,
63
+ hideCloseButton = false,
64
+ scrollBehavior = "outside",
65
+ className = "",
66
+ classNames = {},
67
+ }: ModalProps) {
68
+ const modalRef = useRef<HTMLDivElement>(null);
69
+
70
+ useEffect(() => {
71
+ if (!isOpen) return;
72
+
73
+ const handleEscape = (e: KeyboardEvent) => {
74
+ if (e.key === "Escape" && !isKeyboardDismissDisabled && isDismissable) {
75
+ onOpenChange?.(false);
76
+ }
77
+ };
78
+
79
+ document.addEventListener("keydown", handleEscape);
80
+ document.body.style.overflow = "hidden";
81
+
82
+ return () => {
83
+ document.removeEventListener("keydown", handleEscape);
84
+ document.body.style.overflow = "";
85
+ };
86
+ }, [isOpen, isKeyboardDismissDisabled, isDismissable, onOpenChange]);
87
+
88
+ const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
89
+ if (isDismissable && e.target === e.currentTarget) {
90
+ onOpenChange?.(false);
91
+ }
92
+ };
93
+
94
+ if (!isOpen) return null;
95
+
96
+ const sizeClass = sizeClasses[size];
97
+ const radiusClass = radiusClasses[radius];
98
+
99
+ const handleClose = () => {
100
+ onOpenChange?.(false);
101
+ };
102
+
103
+ const contextValue = {
104
+ onClose: handleClose,
105
+ hideCloseButton,
106
+ };
107
+
108
+ // Clone children to pass onClose prop
109
+ const childrenWithProps = isValidElement(children)
110
+ ? cloneElement(children, { onClose: handleClose } as any)
111
+ : children;
112
+
113
+ const modalContent = (
114
+ <ModalContext.Provider value={contextValue}>
115
+ <div
116
+ className={`fixed inset-0 z-50 flex items-center justify-center ${classNames.wrapper || ""}`}
117
+ onClick={handleBackdropClick}
118
+ >
119
+ {/* Backdrop */}
120
+ <div className={`fixed inset-0 bg-black/50 backdrop-blur-sm ${classNames.backdrop || ""}`} aria-hidden="true" />
121
+
122
+ {/* Modal Content */}
123
+ <div
124
+ ref={modalRef}
125
+ className={`
126
+ relative z-10 bg-background shadow-lg
127
+ ${sizeClass}
128
+ ${radiusClass}
129
+ ${scrollBehavior === "inside" ? "max-h-[90vh] overflow-hidden flex flex-col" : ""}
130
+ ${className}
131
+ ${classNames.base || ""}
132
+ `}
133
+ onClick={(e) => e.stopPropagation()}
134
+ role="dialog"
135
+ aria-modal="true"
136
+ >
137
+ {childrenWithProps}
138
+ </div>
139
+ </div>
140
+ </ModalContext.Provider>
141
+ );
142
+
143
+ return createPortal(modalContent, document.body);
144
+ }
145
+
146
+ // Export sub-components
147
+ Modal.Content = ModalContent;
148
+ Modal.Header = ModalHeader;
149
+ Modal.Body = ModalBody;
150
+ Modal.Footer = ModalFooter;
151
+
152
+ export { ModalBody, ModalContent, ModalFooter, ModalHeader };
@@ -0,0 +1,46 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ export interface UseDisclosureProps {
4
+ defaultOpen?: boolean;
5
+ isOpen?: boolean;
6
+ onOpenChange?: (isOpen: boolean) => void;
7
+ }
8
+
9
+ export function useDisclosure(props: UseDisclosureProps = {}) {
10
+ const { defaultOpen = false, isOpen: controlledIsOpen, onOpenChange } = props;
11
+
12
+ const [uncontrolledIsOpen, setUncontrolledIsOpen] = useState(defaultOpen);
13
+
14
+ const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : uncontrolledIsOpen;
15
+
16
+ const onOpen = useCallback(() => {
17
+ if (controlledIsOpen === undefined) {
18
+ setUncontrolledIsOpen(true);
19
+ }
20
+ onOpenChange?.(true);
21
+ }, [controlledIsOpen, onOpenChange]);
22
+
23
+ const onClose = useCallback(() => {
24
+ if (controlledIsOpen === undefined) {
25
+ setUncontrolledIsOpen(false);
26
+ }
27
+ onOpenChange?.(false);
28
+ }, [controlledIsOpen, onOpenChange]);
29
+
30
+ const onOpenChangeHandler = useCallback(
31
+ (newIsOpen: boolean) => {
32
+ if (controlledIsOpen === undefined) {
33
+ setUncontrolledIsOpen(newIsOpen);
34
+ }
35
+ onOpenChange?.(newIsOpen);
36
+ },
37
+ [controlledIsOpen, onOpenChange],
38
+ );
39
+
40
+ return {
41
+ isOpen,
42
+ onOpen,
43
+ onClose,
44
+ onOpenChange: onOpenChangeHandler,
45
+ };
46
+ }
@@ -0,0 +1,94 @@
1
+ import QRCodeStyling, { type Options } from "qr-code-styling";
2
+ import { useEffect, useRef } from "react";
3
+ import { Skeleton } from "../skeleton";
4
+
5
+ type QRCodeProps = {
6
+ content: string;
7
+ size?: number;
8
+ logo?: string;
9
+ logoBackground?: string;
10
+ };
11
+
12
+ /**
13
+ * QR Code Component
14
+ * Generates a QR code with optional logo embedded directly in the QR code
15
+ * Uses qr-code-styling library which handles logo embedding properly
16
+ */
17
+ export const QRCodeComponent = ({ content, size = 168, logo, logoBackground }: QRCodeProps) => {
18
+ const containerRef = useRef<HTMLDivElement>(null);
19
+ const qrCodeInstanceRef = useRef<QRCodeStyling | null>(null);
20
+
21
+ useEffect(() => {
22
+ if (!content || !containerRef.current) {
23
+ return;
24
+ }
25
+
26
+ const container = containerRef.current;
27
+ const qrCodeOptions: Partial<Options> = {
28
+ width: size,
29
+ height: size,
30
+ data: content,
31
+ margin: 0,
32
+ dotsOptions: {
33
+ color: "#000000",
34
+ type: "square",
35
+ },
36
+ backgroundOptions: {
37
+ color: "#ffffff",
38
+ round: 8,
39
+ },
40
+ cornersSquareOptions: {
41
+ color: "#000000",
42
+ type: "extra-rounded",
43
+ },
44
+ cornersDotOptions: {
45
+ color: "#000000",
46
+ type: "square",
47
+ },
48
+ };
49
+
50
+ if (logo) {
51
+ qrCodeOptions.image = logo;
52
+ qrCodeOptions.imageOptions = {
53
+ crossOrigin: "anonymous",
54
+ margin: 10,
55
+ imageSize: 0.4, // 40% of QR code size
56
+ hideBackgroundDots: true,
57
+ ...(logoBackground && {
58
+ backgroundOptions: {
59
+ color: logoBackground,
60
+ },
61
+ }),
62
+ };
63
+ }
64
+
65
+ // Reuse existing instance and update it, or create new one
66
+ if (qrCodeInstanceRef.current) {
67
+ // Update existing instance instead of creating new one
68
+ qrCodeInstanceRef.current.update(qrCodeOptions);
69
+ } else {
70
+ // Create new instance only on first render
71
+ const qrCode = new QRCodeStyling(qrCodeOptions);
72
+ qrCodeInstanceRef.current = qrCode;
73
+ container.innerHTML = "";
74
+ qrCode.append(container);
75
+ }
76
+
77
+ // Cleanup on unmount
78
+ const cleanupContainer = container;
79
+ return () => {
80
+ if (cleanupContainer && qrCodeInstanceRef.current) {
81
+ cleanupContainer.innerHTML = "";
82
+ qrCodeInstanceRef.current = null;
83
+ }
84
+ };
85
+ }, [content, size, logo, logoBackground]);
86
+
87
+ return (
88
+ <Skeleton isLoaded={!!content} style={{ width: size, height: size }} className="rounded-lg">
89
+ <div className="flex items-center justify-center p-2 rounded-lg">
90
+ <div ref={containerRef} />
91
+ </div>
92
+ </Skeleton>
93
+ );
94
+ };