bsign-customization-full 0.0.1

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 (126) hide show
  1. package/.env +2 -0
  2. package/components.json +21 -0
  3. package/dist/colors/anthracite-gray.webp +0 -0
  4. package/dist/colors/anthracite-gray_50x50.png +0 -0
  5. package/dist/colors/dark-wenge.webp +0 -0
  6. package/dist/colors/dark-wenge_50x50.png +0 -0
  7. package/dist/colors/indian-rosewood.webp +0 -0
  8. package/dist/colors/indian-rosewood_50x50.png +0 -0
  9. package/dist/colors/natural-wood.webp +0 -0
  10. package/dist/colors/natural-wood_50x50.png +0 -0
  11. package/dist/colors/redwood.webp +0 -0
  12. package/dist/colors/redwood_50x50.png +0 -0
  13. package/dist/colors/walnut.webp +0 -0
  14. package/dist/colors/walnut_50x50.png +0 -0
  15. package/dist/html2canvas.esm-BJ_egzt0.js +4802 -0
  16. package/dist/index-Dw5Zc1iD.js +33162 -0
  17. package/dist/index.es-Co1KNpGS.js +6681 -0
  18. package/dist/logo.png +0 -0
  19. package/dist/purify.es-CKpD2xIC.js +552 -0
  20. package/dist/sign-constructor.es.js +4 -0
  21. package/dist/sign-constructor.iife.js +171 -0
  22. package/dist/size-guide.webp +0 -0
  23. package/dist/size.webp +0 -0
  24. package/dist/templates/assets/modern/rectangle-black.webp +0 -0
  25. package/dist/templates/assets/modern/rectangle-white.webp +0 -0
  26. package/dist/templates/assets/modern/square-black.webp +0 -0
  27. package/dist/templates/assets/modern/square-white.webp +0 -0
  28. package/dist/templates/assets/wave.webp +0 -0
  29. package/dist/templates/jure.webp +0 -0
  30. package/dist/templates/modern.webp +0 -0
  31. package/dist/templates/sherwood.webp +0 -0
  32. package/dist/templates/wave.webp +0 -0
  33. package/eslint.config.js +23 -0
  34. package/index.html +13 -0
  35. package/modern-debug.svg +39 -0
  36. package/package.json +62 -0
  37. package/public/colors/anthracite-gray.webp +0 -0
  38. package/public/colors/anthracite-gray_50x50.png +0 -0
  39. package/public/colors/dark-wenge.webp +0 -0
  40. package/public/colors/dark-wenge_50x50.png +0 -0
  41. package/public/colors/indian-rosewood.webp +0 -0
  42. package/public/colors/indian-rosewood_50x50.png +0 -0
  43. package/public/colors/natural-wood.webp +0 -0
  44. package/public/colors/natural-wood_50x50.png +0 -0
  45. package/public/colors/redwood.webp +0 -0
  46. package/public/colors/redwood_50x50.png +0 -0
  47. package/public/colors/walnut.webp +0 -0
  48. package/public/colors/walnut_50x50.png +0 -0
  49. package/public/logo.png +0 -0
  50. package/public/size-guide.webp +0 -0
  51. package/public/size.webp +0 -0
  52. package/public/templates/assets/modern/rectangle-black.webp +0 -0
  53. package/public/templates/assets/modern/rectangle-white.webp +0 -0
  54. package/public/templates/assets/modern/square-black.webp +0 -0
  55. package/public/templates/assets/modern/square-white.webp +0 -0
  56. package/public/templates/assets/wave.webp +0 -0
  57. package/public/templates/jure.webp +0 -0
  58. package/public/templates/modern.webp +0 -0
  59. package/public/templates/sherwood.webp +0 -0
  60. package/public/templates/wave.webp +0 -0
  61. package/src/App.css +43 -0
  62. package/src/AppDemo2.tsx +257 -0
  63. package/src/components/cart-panel.tsx +170 -0
  64. package/src/components/cart-preview.tsx +356 -0
  65. package/src/components/cart-view.tsx +113 -0
  66. package/src/components/constructure-menu.tsx +37 -0
  67. package/src/components/header.tsx +214 -0
  68. package/src/components/heading.tsx +28 -0
  69. package/src/components/icons.tsx +54 -0
  70. package/src/components/import-file-modal.tsx +252 -0
  71. package/src/components/layers/grid-view.tsx +29 -0
  72. package/src/components/layers/image-layer.tsx +128 -0
  73. package/src/components/layers/layer-forms/material-form.tsx +53 -0
  74. package/src/components/layers/layer-forms/size-form.tsx +69 -0
  75. package/src/components/layers/layer-forms/template-form.tsx +39 -0
  76. package/src/components/layers/layer-forms/text-form.tsx +477 -0
  77. package/src/components/layers/layers-container.tsx +259 -0
  78. package/src/components/layers/text-layer.tsx +128 -0
  79. package/src/components/movable-item.tsx +228 -0
  80. package/src/components/preview.tsx +258 -0
  81. package/src/components/resize-button.tsx +83 -0
  82. package/src/components/size-guide-modal.tsx +47 -0
  83. package/src/components/size-guide.tsx +98 -0
  84. package/src/components/ui/button-group.tsx +83 -0
  85. package/src/components/ui/button.tsx +60 -0
  86. package/src/components/ui/checkbox.tsx +30 -0
  87. package/src/components/ui/dialog.tsx +151 -0
  88. package/src/components/ui/input-group.tsx +168 -0
  89. package/src/components/ui/input.tsx +21 -0
  90. package/src/components/ui/label.tsx +22 -0
  91. package/src/components/ui/popover.tsx +54 -0
  92. package/src/components/ui/progress.tsx +28 -0
  93. package/src/components/ui/radio-group.tsx +43 -0
  94. package/src/components/ui/scroll-area.tsx +56 -0
  95. package/src/components/ui/select.tsx +191 -0
  96. package/src/components/ui/separator.tsx +25 -0
  97. package/src/components/ui/sheet.tsx +141 -0
  98. package/src/components/ui/slider.tsx +61 -0
  99. package/src/components/ui/spinner.tsx +15 -0
  100. package/src/components/ui/textarea.tsx +18 -0
  101. package/src/components/ui/toggle-group.tsx +73 -0
  102. package/src/components/ui/toggle.tsx +45 -0
  103. package/src/components/ui/tooltip.tsx +67 -0
  104. package/src/fonts/BEBASNEUE-REGULAR.TTF +0 -0
  105. package/src/fonts/Braille-Regular.ttf +0 -0
  106. package/src/fonts/GOTHICB.TTF +0 -0
  107. package/src/hooks/use-mobile.ts +23 -0
  108. package/src/hooks/use-resize-constraints.ts +62 -0
  109. package/src/index.css +238 -0
  110. package/src/index.tsx +141 -0
  111. package/src/lib/cart-proposal-pdf.ts +350 -0
  112. package/src/lib/config-font.tsx +109 -0
  113. package/src/lib/config.ts +730 -0
  114. package/src/lib/pricing.ts +61 -0
  115. package/src/lib/type-checks.ts +47 -0
  116. package/src/lib/utils.ts +146 -0
  117. package/src/lib/widget-context.tsx +9 -0
  118. package/src/main.tsx +11 -0
  119. package/src/store/cart-store.ts +78 -0
  120. package/src/store/layers-store.ts +337 -0
  121. package/src/vite-env.d.ts +1 -0
  122. package/test/preview.html +37 -0
  123. package/tsconfig.app.json +33 -0
  124. package/tsconfig.json +13 -0
  125. package/tsconfig.node.json +25 -0
  126. package/vite.config.ts +38 -0
@@ -0,0 +1,258 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useIsMobile } from "../hooks/use-mobile";
3
+ import { useLayersStore } from "../store/layers-store";
4
+ import type { LayerImageProps } from "./layers/image-layer";
5
+ import ImageLayer from "./layers/image-layer";
6
+ import type { LayerTextProps } from "./layers/text-layer";
7
+ import TextLayer from "./layers/text-layer";
8
+ import { type TMovableItemProps } from "./movable-item"
9
+ import * as htmlToImage from "html-to-image";
10
+ import { isBackgroundLayer } from "../lib/type-checks";
11
+ import { useCartStore } from "../store/cart-store";
12
+ import GridView from "./layers/grid-view";
13
+ import { getDefaultTemplateOptions, getSizeLimitScale, getTemplateTextFont } from "../lib/config";
14
+
15
+ const PREVIEW_PIXEL_RATIO = 1;
16
+ const CART_IMAGE_PIXEL_RATIO = 3;
17
+ const CSS_INCH_IN_PX = 96;
18
+ const PREVIEW_CONTENT_MARGIN_PX = 80;
19
+ const MOBILE_REFERENCE_SIZE = getDefaultTemplateOptions().selectedSize.inchs;
20
+
21
+ export type LayrProps = LayerImageProps | LayerTextProps | { imageSrc: string } & { hidden?: boolean };
22
+
23
+ export type TLayer<T> = {
24
+ type: 'image' | 'text' | 'background'
25
+ subtype?: 'braille' | 'icon'
26
+ props: T & TMovableItemProps & { hidden?: boolean };
27
+ };
28
+
29
+ // interface PreviewProps {
30
+ // background?: {
31
+ // aspectRatio?: string
32
+ // }
33
+ // }
34
+
35
+ export default function Preview() {
36
+ const { layers, setPreview, cartId, options, change, textCenteringRequest } = useLayersStore();
37
+ const { update: updateCart } = useCartStore();
38
+ const isMobile = useIsMobile();
39
+ const canvasRef = useRef<HTMLDivElement | null>(null);
40
+ const lastAutoCenteredTemplateIdRef = useRef<string | null>(null);
41
+
42
+ const [backgroundLayer, setBackground] = useState<string>();
43
+
44
+ const containerRef = useRef<HTMLDivElement>(null)
45
+ const selectedSize = options.selectedSize.inchs;
46
+ const templateTextFont = getTemplateTextFont(options.selectedTemplateId);
47
+ const referenceWidthPx = MOBILE_REFERENCE_SIZE.w * CSS_INCH_IN_PX + PREVIEW_CONTENT_MARGIN_PX;
48
+ const referenceHeightPx = MOBILE_REFERENCE_SIZE.h * CSS_INCH_IN_PX + PREVIEW_CONTENT_MARGIN_PX;
49
+ const currentWidthPx = selectedSize.w * CSS_INCH_IN_PX + PREVIEW_CONTENT_MARGIN_PX;
50
+ const currentHeightPx = selectedSize.h * CSS_INCH_IN_PX + PREVIEW_CONTENT_MARGIN_PX;
51
+ const mobilePreviewScale = isMobile
52
+ ? Math.min(referenceWidthPx / currentWidthPx, referenceHeightPx / currentHeightPx)
53
+ : 1;
54
+ const previewSizeLimitScale = getSizeLimitScale(options.selectedSize);
55
+
56
+ useEffect(() => {
57
+ const layerBackground = layers.find(layer => layer.type === 'background');
58
+ if (layerBackground && isBackgroundLayer(layerBackground) && layerBackground.props.imageSrc !== backgroundLayer)
59
+ setBackground(layerBackground.props.imageSrc)
60
+
61
+ const timer = setTimeout(() => {
62
+ generateImage({ pixelRatio: PREVIEW_PIXEL_RATIO, updatePreviewState: true });
63
+ }, 100);
64
+
65
+ return () => clearTimeout(timer);
66
+ }, [options, layers[0], layers[1], layers[2], layers[3], layers[4], layers[5]])
67
+
68
+ useEffect(() => {
69
+ const timer = setTimeout(() => {
70
+ refreshCart()
71
+ }, 100);
72
+ return () => clearTimeout(timer);
73
+ }, [cartId, options, layers[0], layers[1], layers[2], layers[3], layers[4], layers[5]])
74
+
75
+ const generateImage = async ({
76
+ pixelRatio = PREVIEW_PIXEL_RATIO,
77
+ updatePreviewState = true,
78
+ }: {
79
+ pixelRatio?: number;
80
+ updatePreviewState?: boolean;
81
+ } = {}) => {
82
+ if (!canvasRef.current) return undefined;
83
+
84
+ try {
85
+ const previewUrl = await htmlToImage.toPng(canvasRef.current, {
86
+ cacheBust: true,
87
+ pixelRatio,
88
+ skipFonts: false,
89
+ });
90
+
91
+ if (updatePreviewState) {
92
+ setPreview(previewUrl);
93
+ }
94
+
95
+ return previewUrl;
96
+ } catch (error) {
97
+ console.error(error);
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ const refreshCart = async () => {
103
+ if (cartId) {
104
+ const img = await generateImage({
105
+ pixelRatio: CART_IMAGE_PIXEL_RATIO,
106
+ updatePreviewState: false,
107
+ });
108
+ if (img) {
109
+ updateCart(cartId, {
110
+ img,
111
+ sign: layers,
112
+ options
113
+ });
114
+ }
115
+ }
116
+ }
117
+
118
+ const centerTextBlock = (): boolean => {
119
+ const textLayers = layers
120
+ .map((layer, index) => ({ layer, index }))
121
+ .filter(({ layer }) => layer.type === "text" && layer.subtype !== "braille");
122
+
123
+ if (!textLayers.length) return false;
124
+ const parent = containerRef.current?.offsetParent as HTMLElement;
125
+ if (!parent) return false;
126
+
127
+ const centerX = parent.clientWidth / 2;
128
+ const centerY = parent.clientHeight / 2;
129
+
130
+ const positionedTextLayers = textLayers.filter(({ layer }) =>
131
+ typeof layer.props.coordinates?.x === "number" && typeof layer.props.coordinates?.y === "number"
132
+ );
133
+
134
+ if (!positionedTextLayers.length) return false;
135
+
136
+ const textXCoordinates = positionedTextLayers.map(({ layer }) => layer.props.coordinates.x as number);
137
+ const textYCoordinates = positionedTextLayers.map(({ layer }) => layer.props.coordinates.y as number);
138
+
139
+ const currentTextCenterX = (Math.min(...textXCoordinates) + Math.max(...textXCoordinates)) / 2;
140
+ const currentTextCenterY = (Math.min(...textYCoordinates) + Math.max(...textYCoordinates)) / 2;
141
+
142
+ const deltaX = centerX - currentTextCenterX;
143
+ const deltaY = centerY - currentTextCenterY;
144
+
145
+ if (Math.abs(deltaX) < 0.01 && Math.abs(deltaY) < 0.01) return true;
146
+
147
+ positionedTextLayers.forEach(({ layer, index }) => {
148
+ change(index, {
149
+ coordinates: {
150
+ x: (layer.props.coordinates.x as number) + deltaX,
151
+ y: (layer.props.coordinates.y as number) + deltaY,
152
+ }
153
+ });
154
+ });
155
+
156
+ const brailleLayers = layers
157
+ .map((layer, index) => ({ layer, index }))
158
+ .filter(({ layer }) => layer.subtype === "braille" &&
159
+ typeof layer.props.coordinates?.x === "number" &&
160
+ typeof layer.props.coordinates?.y === "number"
161
+ );
162
+
163
+ brailleLayers.forEach(({ layer, index }) => {
164
+ change(index, {
165
+ coordinates: {
166
+ x: (layer.props.coordinates.x as number) + deltaX,
167
+ y: (layer.props.coordinates.y as number) + deltaY,
168
+ }
169
+ });
170
+ });
171
+ return true;
172
+ };
173
+
174
+ // Automatic centering on startup and when template changes.
175
+ useEffect(() => {
176
+ if (!backgroundLayer) return;
177
+ if (lastAutoCenteredTemplateIdRef.current === options.selectedTemplateId) return;
178
+
179
+ const isCentered = centerTextBlock();
180
+ if (isCentered) {
181
+ lastAutoCenteredTemplateIdRef.current = options.selectedTemplateId;
182
+ }
183
+ }, [backgroundLayer, layers, options.selectedTemplateId]);
184
+
185
+ // Manual centering by "Center text" button.
186
+ useEffect(() => {
187
+ if (textCenteringRequest === 0) return;
188
+ centerTextBlock();
189
+ }, [textCenteringRequest])
190
+
191
+ return (
192
+ <>
193
+ {(backgroundLayer) && <div>
194
+ <div
195
+ className={isMobile ? "overflow-hidden" : undefined}
196
+ style={isMobile ? {
197
+ width: `${referenceWidthPx}px`,
198
+ height: `${referenceHeightPx}px`,
199
+ } : undefined}
200
+ >
201
+ <div
202
+ className={isMobile ? "h-fit w-fit origin-top-left" : undefined}
203
+ style={isMobile ? { transform: `scale(${mobilePreviewScale})` } : undefined}
204
+ >
205
+ <div
206
+ className="relative overflow-hidden origin-top transition-transform duration-500 ease-in-out"
207
+ ref={canvasRef}
208
+ style={{ transform: `scale(${previewSizeLimitScale})` }}
209
+ >
210
+
211
+ <div className="m-10" ref={containerRef}>
212
+ {/* Layer 1 - Main Background */}
213
+ <div
214
+ className="bg-center bg-no-repeat relative pointer-events-none transition-[width,height] duration-500 ease-in-out will-change-[width,height]"
215
+ style={{
216
+ backgroundImage: `url(${backgroundLayer})`,
217
+ backgroundColor: backgroundLayer ? "transparent" : "#f3f4f6",
218
+ backgroundSize: "100% 100%", // Avoid side clipping for rectangular signs when source aspect ratio differs.
219
+ width: `${options.selectedSize.inchs.w}in`,
220
+ height: `${options.selectedSize.inchs.h}in`,
221
+ }}
222
+ >
223
+ {options.gridCm && <div className="absolute inset-0 z-50 pointer-events-none">
224
+ <GridView h={options.selectedSize.cm.h} w={options.selectedSize.cm.w} color="color-mix(in oklab, var(--color-red-600) 50%, transparent)" />
225
+ </div>}
226
+ {options.gridInch && <div className="absolute inset-0 z-50 pointer-events-none">
227
+ <GridView h={options.selectedSize.inchs.h} w={options.selectedSize.inchs.w} color="color-mix(in oklab, var(--color-purple-600) 50%, transparent)" width={2} />
228
+ </div>}
229
+ </div>
230
+
231
+
232
+ {layers && layers.map((layer, index) => !layer.props.hidden ? (
233
+ <>
234
+ {layer.subtype === 'icon' && (
235
+ <ImageLayer key={`layer_${index}`} layerIndex={index} />
236
+ )}
237
+
238
+ {layer.type === 'image' && (
239
+ <ImageLayer key={`layer_${index}`} layerIndex={index} />
240
+ )}
241
+
242
+ {(layer.type === 'text' && layer.subtype !== 'braille') && (
243
+ <TextLayer key={`layer_${index}`} layerIndex={index} font={templateTextFont} />
244
+ )}
245
+
246
+ {layer.subtype === 'braille' && (
247
+ <TextLayer key={`layer_${index}`} layerIndex={index} font='font-braille' />
248
+ )}
249
+ </>
250
+ ) : null)}
251
+ </div>
252
+ </div>
253
+ </div>
254
+ </div>
255
+ </div>}
256
+ </>
257
+ )
258
+ }
@@ -0,0 +1,83 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import { cn } from "../lib/utils";
3
+ import { useCallback, useState } from "react";
4
+
5
+ export type ResizeProps = {
6
+ isMouseMoveLeftDown: boolean,
7
+ isMouseMoveRightUp: boolean,
8
+ isMouseMoveLeftUp: boolean,
9
+ isMouseMoveRightDown: boolean,
10
+ isMouseMoveLeft: boolean,
11
+ isMouseMoveRight: boolean,
12
+ isMouseMoveUp: boolean,
13
+ }
14
+
15
+ const resizableItemVariants = cva(
16
+ "absolute bg-blue-500 hover:bg-red-500 rounded-full h-[14px] w-[14px] translate-y-0",
17
+ {
18
+ variants: {
19
+ position: {
20
+ leftTop: "-top-[10px] -left-[10px]",
21
+ leftCenter: "bottom-1/2 -left-[10px]",
22
+ leftBottom: "-bottom-[10px] -left-[10px]",
23
+ rightTop: "-top-[10px] -right-[10px]",
24
+ rightCenter: "bottom-1/2 -right-[10px]",
25
+ rightBottom: "-bottom-[10px] -right-[10px]",
26
+ bottomCenter: "-bottom-[10px] left-1/2",
27
+ topCenter: "-top-[10px] left-1/2",
28
+ },
29
+ cursor: {
30
+ 'nwse-resize': 'cursor-nwse-resize',
31
+ 'nesw-resize': 'cursor-nesw-resize',
32
+ 'e-resize': 'cursor-e-resize',
33
+ 'ns-resize': 'cursor-ns-resize'
34
+ }
35
+ }
36
+ }
37
+ )
38
+
39
+ export default function ResizeButton(props: VariantProps<typeof resizableItemVariants> & {
40
+ onUpdate: (move: ResizeProps) => void;
41
+ }) {
42
+ const [isDragging, setIsDragging] = useState(false)
43
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
44
+
45
+ const handleMouseDown = useCallback(
46
+ (e: React.MouseEvent) => {
47
+ e.preventDefault()
48
+ setIsDragging(true)
49
+ setDragStart({ x: e.clientX, y: e.clientY })
50
+ }, [],
51
+ )
52
+
53
+ const handleMouseMove = useCallback(
54
+ (e: React.MouseEvent) => {
55
+ if (!isDragging) return
56
+
57
+ const isMouseMoveLeftDown = e.clientX < dragStart.x && e.clientY > dragStart.y
58
+ const isMouseMoveRightUp = e.clientX > dragStart.x && e.clientY < dragStart.y
59
+ const isMouseMoveLeftUp = e.clientX < dragStart.x && e.clientY < dragStart.y
60
+ const isMouseMoveRightDown = e.clientX > dragStart.x && e.clientY > dragStart.y
61
+
62
+ const isMouseMoveLeft = e.clientX < dragStart.x
63
+ const isMouseMoveRight = e.clientX > dragStart.x
64
+ const isMouseMoveUp = e.clientY < dragStart.y
65
+
66
+ props.onUpdate({ isMouseMoveLeftDown, isMouseMoveRightUp, isMouseMoveLeftUp, isMouseMoveRightDown, isMouseMoveLeft, isMouseMoveRight, isMouseMoveUp })
67
+ },
68
+ [isDragging],
69
+ )
70
+
71
+ const handleMouseUp = useCallback(() => {
72
+ setIsDragging(false)
73
+ }, [])
74
+
75
+ return (
76
+ <div
77
+ onMouseMove={handleMouseMove}
78
+ onMouseUp={handleMouseUp}
79
+ onMouseDown={handleMouseDown}
80
+ className={cn(resizableItemVariants(props))}
81
+ >{isDragging && 'Dragging'}</div>
82
+ )
83
+ }
@@ -0,0 +1,47 @@
1
+ import {
2
+ Dialog,
3
+ DialogPortal,
4
+ DialogOverlay,
5
+ } from "./ui/dialog"
6
+ import * as DialogPrimitive from "@radix-ui/react-dialog"
7
+ import { cn } from "../lib/utils"
8
+ import { X } from "lucide-react"
9
+
10
+ interface ImportFileModalProps {
11
+ open: boolean
12
+ onOpenChange: (open: boolean) => void
13
+ }
14
+
15
+ export function SizeGuideModal({
16
+ open,
17
+ onOpenChange,
18
+ }: ImportFileModalProps) {
19
+
20
+ return (
21
+ <Dialog open={open} onOpenChange={onOpenChange}>
22
+ <DialogPortal>
23
+ <DialogOverlay className="bg-black/40" />
24
+ <DialogPrimitive.Content
25
+ className={cn(
26
+ "fixed left-1/2 top-1/2 z-50 max-h-[90vh] w-[calc(100%-1rem)] max-w-[1600px] overflow-auto bg-background -translate-x-1/2 -translate-y-1/2 sm:w-[calc(100%-2rem)]",
27
+ "rounded-2xl",
28
+ "duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out",
29
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
30
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
31
+ "data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
32
+ "data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
33
+ "focus:outline-none"
34
+ )}
35
+ >
36
+ <div className="w-full px-4 pt-2 flex justify-end">
37
+ <DialogPrimitive.Close className="flex items-center gap-2 text-sm font-medium text-foreground hover:text-foreground/80 transition-colors">
38
+ <X className="h-4 w-4" />
39
+ Close
40
+ </DialogPrimitive.Close>
41
+ </div>
42
+ <img src="/size-guide.webp" className="w-full h-auto rounded-2xl object-contain" />
43
+ </DialogPrimitive.Content>
44
+ </DialogPortal>
45
+ </Dialog>
46
+ )
47
+ }
@@ -0,0 +1,98 @@
1
+ import { Percent, X } from "lucide-react"
2
+ import { Button } from "./ui/button"
3
+ import { useState } from "react"
4
+ import { useIsMobile } from "../hooks/use-mobile"
5
+
6
+ export type TGuide = {
7
+ price: string;
8
+ qty: number;
9
+ discount?: number;
10
+ badgeColor?: string;
11
+ }
12
+
13
+ function Guide({ items, currencySymbol = "$" }: {
14
+ items: TGuide[];
15
+ currencySymbol?: string;
16
+ }) {
17
+ return (
18
+ <div className="quantity-discount">
19
+ <div className="qty-discount--list">
20
+
21
+ {items.map(({ price, qty, discount, badgeColor }) => (
22
+ <div className="qty-discount--item" key={qty}>
23
+ <p className="qty-discount--item-qty">{qty} Item</p>
24
+ <p className="qty-discount--item-price">
25
+ {currencySymbol}<span id="main-product-price">
26
+ {price}
27
+ </span>
28
+ </p>
29
+ <p className="qty-discount--item-hm">each</p>
30
+
31
+ {discount && (
32
+ <span
33
+ className="qty-discount--item-badge"
34
+ style={{
35
+ background: badgeColor ?? "#FFBE00",
36
+ }}
37
+ >
38
+ SAVE {discount}%
39
+ </span>
40
+ )}
41
+ </div>
42
+ ))}
43
+
44
+ </div>
45
+
46
+ <p className="qty-discount--info">
47
+ Special pricing is available for B2B clients and Property Managers. Contact us to learn more.
48
+ </p>
49
+ </div>
50
+ )
51
+ }
52
+
53
+ export default function PriceGuide({ guide, currencySymbol = "$" }: {
54
+ guide: TGuide[];
55
+ currencySymbol?: string;
56
+ }) {
57
+ const [open, setOpen] = useState(false)
58
+ const isMobile = useIsMobile()
59
+
60
+ return (
61
+ <div className="relative">
62
+ <Button
63
+ variant={"green"}
64
+ size={"lg"}
65
+ onClick={() => setOpen(prev => !prev)}
66
+ className={`${open ? "bg-[#107E12] text-white" : ""} hidden md:flex`}
67
+ >
68
+ <Percent className="size-4.5" />
69
+ Price guide
70
+ </Button>
71
+
72
+ <span className="flex md:hidden items-center gap-1.5 text-[#107E12] underline underline-offset-4" onClick={() => setOpen(prev => !prev)}>
73
+ <Percent className="size-3.5" />
74
+ Price guide
75
+ </span>
76
+
77
+ {open && (
78
+ <>
79
+ {isMobile && (
80
+ <button
81
+ type="button"
82
+ className="fixed inset-0 z-40 bg-black/30"
83
+ onClick={() => setOpen(false)}
84
+ aria-label="Close price guide"
85
+ />
86
+ )}
87
+ <div className={`border border-[#E6E6E6] rounded-xl bg-white p-4 z-50 ${isMobile ? "fixed inset-x-3 bottom-3 max-h-[75vh] overflow-auto" : "absolute -top-53 max-w-[600px]"}`}>
88
+ <div className="flex items-start justify-between">
89
+ <h6 className="mb-4 text-[18px] font-semibold">Price guide</h6>
90
+ <X className="size-4 cursor-pointer" onClick={() => setOpen(false)} />
91
+ </div>
92
+ <Guide items={guide} currencySymbol={currencySymbol} />
93
+ </div>
94
+ </>
95
+ )}
96
+ </div>
97
+ )
98
+ }
@@ -0,0 +1,83 @@
1
+ import { Slot } from "@radix-ui/react-slot"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { cn } from "../../lib/utils"
4
+ import { Separator } from "./separator"
5
+
6
+
7
+ const buttonGroupVariants = cva(
8
+ "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
9
+ {
10
+ variants: {
11
+ orientation: {
12
+ horizontal:
13
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
14
+ vertical:
15
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
16
+ },
17
+ },
18
+ defaultVariants: {
19
+ orientation: "horizontal",
20
+ },
21
+ }
22
+ )
23
+
24
+ function ButtonGroup({
25
+ className,
26
+ orientation,
27
+ ...props
28
+ }: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
29
+ return (
30
+ <div
31
+ role="group"
32
+ data-slot="button-group"
33
+ data-orientation={orientation}
34
+ className={cn(buttonGroupVariants({ orientation }), className)}
35
+ {...props}
36
+ />
37
+ )
38
+ }
39
+
40
+ function ButtonGroupText({
41
+ className,
42
+ asChild = false,
43
+ ...props
44
+ }: React.ComponentProps<"div"> & {
45
+ asChild?: boolean
46
+ }) {
47
+ const Comp = asChild ? Slot : "div"
48
+
49
+ return (
50
+ <Comp
51
+ className={cn(
52
+ "bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
53
+ className
54
+ )}
55
+ {...props}
56
+ />
57
+ )
58
+ }
59
+
60
+ function ButtonGroupSeparator({
61
+ className,
62
+ orientation = "vertical",
63
+ ...props
64
+ }: React.ComponentProps<typeof Separator>) {
65
+ return (
66
+ <Separator
67
+ data-slot="button-group-separator"
68
+ orientation={orientation}
69
+ className={cn(
70
+ "bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
71
+ className
72
+ )}
73
+ {...props}
74
+ />
75
+ )
76
+ }
77
+
78
+ export {
79
+ ButtonGroup,
80
+ ButtonGroupSeparator,
81
+ ButtonGroupText,
82
+ buttonGroupVariants,
83
+ }
@@ -0,0 +1,60 @@
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+ import { cn } from "../../lib/utils"
5
+
6
+ const buttonVariants = cva(
7
+ "cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-[16px] font-semibold transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
12
+ destructive:
13
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
14
+ outline:
15
+ "border bg-background border-black hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
16
+ secondary:
17
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
18
+ ghost:
19
+ "bg-[#F2F2F2] hover:text-accent-foreground dark:hover:bg-accent/50",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ green: "text-[#107E12] bg-white border border-[#107E12]",
22
+ },
23
+ size: {
24
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
25
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
26
+ lg: "h-[56px] rounded-md px-6 has-[>svg]:px-4",
27
+ icon: "size-9",
28
+ "icon-sm": "size-8",
29
+ "icon-lg": "size-10",
30
+ },
31
+ },
32
+ defaultVariants: {
33
+ variant: "default",
34
+ size: "default",
35
+ },
36
+ }
37
+ )
38
+
39
+ function Button({
40
+ className,
41
+ variant,
42
+ size,
43
+ asChild = false,
44
+ ...props
45
+ }: React.ComponentProps<"button"> &
46
+ VariantProps<typeof buttonVariants> & {
47
+ asChild?: boolean
48
+ }) {
49
+ const Comp = asChild ? Slot : "button"
50
+
51
+ return (
52
+ <Comp
53
+ data-slot="button"
54
+ className={cn(buttonVariants({ variant, size, className }))}
55
+ {...props}
56
+ />
57
+ )
58
+ }
59
+
60
+ export { Button, buttonVariants }
@@ -0,0 +1,30 @@
1
+ import * as React from "react"
2
+ import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
3
+ import { CheckIcon } from "lucide-react"
4
+ import { cn } from "../../lib/utils"
5
+
6
+
7
+ function Checkbox({
8
+ className,
9
+ ...props
10
+ }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
11
+ return (
12
+ <CheckboxPrimitive.Root
13
+ data-slot="checkbox"
14
+ className={cn(
15
+ "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
16
+ className
17
+ )}
18
+ {...props}
19
+ >
20
+ <CheckboxPrimitive.Indicator
21
+ data-slot="checkbox-indicator"
22
+ className="flex items-center justify-center text-current transition-none"
23
+ >
24
+ <CheckIcon className="size-3.5" />
25
+ </CheckboxPrimitive.Indicator>
26
+ </CheckboxPrimitive.Root>
27
+ )
28
+ }
29
+
30
+ export { Checkbox }