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,37 @@
1
+ import { type JSX, type ReactNode } from "react";
2
+ import { ScrollArea } from "./ui/scroll-area";
3
+
4
+ export default function ConstructureSidebar({ items, onSelectItem, children, selectedItem }: {
5
+ items: Array<{
6
+ label: string;
7
+ icon: (props: React.ComponentProps<"svg">) => JSX.Element;
8
+ }>;
9
+ selectedItem: string;
10
+ onSelectItem: (label: string) => void;
11
+ children: ReactNode;
12
+ }) {
13
+ return (
14
+ <div className="flex h-full w-full flex-col border-b border-[#D6D6D6] md:flex-row md:border-r md:border-b-0">
15
+ <div className="flex w-full items-center gap-3 overflow-x-auto border-b border-[#D6D6D6] bg-[#F4F4F4] p-3 md:h-full md:w-[110px] md:flex-col md:items-center md:gap-4 md:overflow-visible md:border-b-0 md:py-[15px] md:rounded-tl-3xl 2xl:w-[200px]">
16
+ <img src="/logo.png" className="h-[30px] w-[74px] shrink-0 md:h-[37px] md:w-[92px]" />
17
+ {items.map(item => {
18
+ const Icon = item.icon;
19
+ return (
20
+ <div
21
+ key={item.label}
22
+ className={`bg-white h-[74px] w-[74px] shrink-0 flex flex-col items-center justify-center gap-1 cursor-pointer rounded-xl text-xs md:h-[120px] md:w-[120px] md:gap-[10px] md:rounded-2xl md:text-base ${selectedItem === item.label ? 'outline-[#111111] outline-2' : ''}`}
23
+ onClick={() => onSelectItem(item.label)}
24
+ >
25
+ {<Icon fill="black" color="black" />}
26
+ <p>{item.label}</p>
27
+ </div>
28
+ )
29
+ })}
30
+ </div>
31
+
32
+ <ScrollArea className="min-h-0 max-h-[20dvh] overflow-hidden p-3 md:h-full md:max-h-none md:p-4 md:min-w-[350px] md:max-w-[400px] 2xl:max-w-[470px] md:w-[400px]">
33
+ {children}
34
+ </ScrollArea>
35
+ </div>
36
+ )
37
+ }
@@ -0,0 +1,214 @@
1
+ import { Download, PlusIcon, Redo2, RefreshCcw, Undo2, X } from "lucide-react"
2
+ import { INITIAL_STATE, useLayersStore } from "../store/layers-store"
3
+ import { createTemplateLayers, getDefaultTemplateOptions } from "../lib/config";
4
+ import { isTextLayer } from "../lib/type-checks";
5
+ import { sleep } from "../lib/utils";
6
+ import { useCartStore } from "../store/cart-store";
7
+ import { Button } from "./ui/button";
8
+ import { Input } from "./ui/input";
9
+ import { useState, type JSX, type ReactNode } from "react";
10
+ import Heading from "./heading";
11
+ import { Textarea } from "./ui/textarea";
12
+ import { ImportFileModal } from "./import-file-modal";
13
+
14
+ type TButton = {
15
+ text?: string;
16
+ form?: JSX.Element;
17
+ icon?: ReactNode;
18
+ action?: () => void;
19
+ }
20
+
21
+ function FormAddition({ onSubmit, close, tooltip, heading, inputType, inputName, inputPlaceholder }: {
22
+ onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
23
+ close: () => void;
24
+ tooltip: string;
25
+ heading: string;
26
+ inputName: string;
27
+ inputPlaceholder: string;
28
+ inputType: 'standard' | 'textarea';
29
+ }) {
30
+
31
+ return (
32
+ <form onSubmit={onSubmit}>
33
+ <div className="flex flex-col gap-4">
34
+ <div className="flex justify-between items-center">
35
+ <Heading tooltipContentClassName="max-w-[200px]" tooltip={tooltip}>{heading}</Heading>
36
+ <X className="size-4" onClick={close} />
37
+ </div>
38
+ {inputType === 'standard' ? (
39
+ <Input placeholder={inputPlaceholder} name={inputName} className="text-center" />
40
+ ) : (
41
+ <Textarea placeholder={inputPlaceholder} name={inputName} />
42
+ )}
43
+ <Button type="submit" variant={"outline"} ><PlusIcon /> Add to cart</Button>
44
+ </div>
45
+ </form>
46
+ )
47
+ }
48
+
49
+ export default function Header() {
50
+
51
+ const { clearAll, init, layers, setCartId, options, undo, redo } = useLayersStore();
52
+ const { add } = useCartStore()
53
+ const [openForm, setOpenForm] = useState<TButton>();
54
+
55
+ const [openImport, setOpenImport] = useState(false);
56
+
57
+ const duplicateCartItemWithAnotherText = async (text: string) => {
58
+ const temp = [...layers];
59
+ const indexText = temp.findIndex(layer => layer.type === 'text' && layer.subtype !== 'braille');
60
+ const indexBraille = temp.findIndex(layer => layer.subtype === 'braille');
61
+
62
+ const textLayer = temp[indexText];
63
+ const brailleLayer = temp[indexBraille];
64
+
65
+ if (isTextLayer(textLayer)) {
66
+ temp[indexText] = {
67
+ ...temp[indexText],
68
+ props: {
69
+ ...temp[indexText].props,
70
+ text
71
+ }
72
+ }
73
+ }
74
+
75
+ if (isTextLayer(brailleLayer)) {
76
+ temp[indexBraille] = {
77
+ ...temp[indexBraille],
78
+ props: {
79
+ ...temp[indexBraille].props,
80
+ text
81
+ }
82
+ }
83
+ }
84
+
85
+ const newItem = add(temp, '', options);
86
+
87
+ setCartId(newItem.id);
88
+ init(newItem.sign);
89
+ await sleep(200);
90
+ }
91
+
92
+ const numericalAddition = async (e: React.FormEvent<HTMLFormElement>) => {
93
+ e.preventDefault()
94
+
95
+ const formData = new FormData(e.currentTarget)
96
+
97
+ const input = formData.get('numerical') as string;
98
+
99
+ const inputValues = input.split('-');
100
+
101
+ for (let i = Number(inputValues[0]) - 1; i <= Number(inputValues[1]) - 1; i += 1) {
102
+ await duplicateCartItemWithAnotherText((i + 1).toString())
103
+ }
104
+ }
105
+
106
+ const multiplyAddition = async (e: React.FormEvent<HTMLFormElement>) => {
107
+ e.preventDefault()
108
+
109
+ const formData = new FormData(e.currentTarget)
110
+
111
+ const input = formData.get('multiply') as string;
112
+
113
+ const inputValues = input.split(',');
114
+
115
+ for (const value of inputValues) {
116
+ await duplicateCartItemWithAnotherText(value)
117
+
118
+ }
119
+ }
120
+
121
+ const [displayForm, setDisplayForm] = useState(false);
122
+
123
+ const buttons: TButton[] = [
124
+ {
125
+ text: 'Restart',
126
+ icon: <RefreshCcw className="size-4.5" color="#EC1B23" />,
127
+ action: () => {
128
+ const defaults = getDefaultTemplateOptions();
129
+ clearAll({
130
+ ...INITIAL_STATE,
131
+ layers: createTemplateLayers({
132
+ templateId: defaults.selectedTemplateId,
133
+ shapeId: defaults.selectedShapeId,
134
+ materialId: defaults.selectedMaterialId,
135
+ preferredSizeId: defaults.selectedSize.id,
136
+ }),
137
+ });
138
+ setCartId(undefined);
139
+ }
140
+ },
141
+ {
142
+ icon: <Undo2 className="size-4.5" color="#8F8F8F" onClick={() => undo()} />,
143
+ action: () => { }
144
+ },
145
+ {
146
+ icon: <Redo2 className="size-4.5" color="#8F8F8F" onClick={() => redo()} />,
147
+ action: () => { }
148
+ },
149
+ {
150
+ text: 'Bulk add text',
151
+ form: <FormAddition
152
+ onSubmit={multiplyAddition}
153
+ close={() => setDisplayForm(false)}
154
+ tooltip="To create multiple tables with different text in a single line, enter all variants separated by commas. For example: Room 1, Room 2, Room 3...
155
+ Important: if your tables contain more than one line of text, it’s recommended to use a CSV file for import (UPLOAD option)."
156
+ heading="Bulk add text"
157
+ inputName="multiply"
158
+ inputType="textarea"
159
+ inputPlaceholder="Room 1, Room 2, Room 3..."
160
+ />
161
+ },
162
+ {
163
+ text: 'Bulk add number',
164
+ form: <FormAddition
165
+ onSubmit={numericalAddition}
166
+ close={() => setDisplayForm(false)}
167
+ tooltip="If you want to create signs within a specific range, enter the desired numbers in the field below. For example, to generate signs for hotel numbers 2, 3, 4… 10, enter the range 2-10."
168
+ heading="Bulk add number"
169
+ inputName="numerical"
170
+ inputType="standard"
171
+ inputPlaceholder="1-10"
172
+ />
173
+ },
174
+ {
175
+ text: 'Import file',
176
+ icon: <Download className="size-4.5" />,
177
+ action: () => setOpenImport(true)
178
+ },
179
+ ]
180
+
181
+ return (
182
+ <div className="bg-white relative z-[120] hidden w-full gap-2 overflow-visible border-b border-[#D6D6D6] px-3 py-3 md:flex md:flex-wrap md:items-center md:gap-6 md:px-4 md:py-5">
183
+ <ImportFileModal open={openImport} onOpenChange={setOpenImport} />
184
+ {buttons.map(btn => (
185
+ <div
186
+ className="relative shrink-0 select-none"
187
+
188
+ key={btn.text}
189
+ >
190
+ <button
191
+ type="button"
192
+ className="flex items-center gap-2 whitespace-nowrap text-sm font-medium cursor-pointer md:text-base"
193
+ onClick={() => {
194
+ if (btn.form) {
195
+ if (displayForm && btn.text === openForm?.text && btn.icon === openForm?.icon) setDisplayForm(false);
196
+ else setDisplayForm(true);
197
+ setOpenForm(btn)
198
+ };
199
+ if (btn.action) btn.action();
200
+ }}
201
+ >
202
+ {btn.icon && btn.icon}
203
+ {btn.text && btn.text}
204
+ </button>
205
+ {(displayForm && btn.form && btn.text === openForm?.text && openForm?.icon === btn.icon) && (
206
+ <div className="absolute left-0 top-8 z-[130] w-[min(20rem,calc(100vw-1.5rem))] rounded-xl border border-[#E6E6E6] bg-white p-4 md:min-w-[240px] md:w-auto">
207
+ {btn.form}
208
+ </div>
209
+ )}
210
+ </div>
211
+ ))}
212
+ </div>
213
+ )
214
+ }
@@ -0,0 +1,28 @@
1
+ import type { ReactNode } from "react";
2
+ import { Label } from "./ui/label";
3
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
4
+ import { Info } from "lucide-react";
5
+
6
+ export default function Heading({ htmlFor, required, children, tooltip, tooltipContentClassName }: {
7
+ htmlFor?: string;
8
+ required?: boolean;
9
+ tooltip?: string;
10
+ tooltipContentClassName?: string;
11
+ children: ReactNode;
12
+ }) {
13
+ return (
14
+ <div className="flex items-center gap-1.5">
15
+ {tooltip && <Tooltip>
16
+ <TooltipTrigger asChild>
17
+ <Info className="h-3.5 w-3.5 text-gray-500 cursor-help" />
18
+ </TooltipTrigger>
19
+ <TooltipContent className={tooltipContentClassName}>
20
+ <p>{tooltip}</p>
21
+ </TooltipContent>
22
+ </Tooltip>}
23
+ <Label htmlFor={htmlFor} className="font-semibold text-black text-[16px]">
24
+ {children} {required && <span className="text-red-500">*</span>}
25
+ </Label>
26
+ </div>
27
+ )
28
+ }
@@ -0,0 +1,54 @@
1
+ export function TextSvgIcon(props: React.ComponentProps<"svg">) {
2
+ return (
3
+ <svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
4
+ <path d="M3.30174 0.125015C1.81817 0.523495 0.522003 1.83612 0.13159 3.32846C0.0691243 3.5863 0.0144665 4.1176 0.0144665 4.55514C0.0144665 5.40679 0.217481 6.17249 0.623511 6.86788C0.928033 7.39137 1.77913 8.20395 2.29448 8.47741L2.74736 8.71181V20.002V31.2922L2.32571 31.511C1.38091 32.011 0.623511 32.9173 0.233098 34.019C-0.149507 35.1285 -0.0558078 36.5271 0.475154 37.5663C1.07639 38.7304 2.11489 39.5586 3.37983 39.8868C4.12942 40.0821 5.41778 40.0196 6.13614 39.7383C7.1356 39.3555 8.04136 38.5664 8.50205 37.6835L8.72068 37.2693H20.0036H31.2866L31.5052 37.6835C31.9659 38.5664 32.9185 39.3789 33.9414 39.7696C34.5894 40.0118 35.9168 40.0743 36.6274 39.8868C38.2125 39.4727 39.4696 38.2226 39.8756 36.6677C39.9459 36.4099 39.9928 35.8864 39.9928 35.4332C39.9928 34.7769 39.9537 34.5503 39.7663 34.0112C39.3915 32.9408 38.6107 32.0032 37.6737 31.511L37.2599 31.2922V20.002V8.71181L37.6737 8.49304C38.6107 8.00861 39.4228 7.01633 39.7897 5.92247C40.0318 5.20365 40.0708 4.07072 39.8756 3.32846C39.4774 1.81268 38.1812 0.515678 36.6664 0.117203C36.401 0.0468826 35.8856 0 35.4249 0C34.769 0 34.5426 0.0390663 34.0116 0.218773C32.9107 0.609436 32.0049 1.36733 31.5052 2.31273L31.2866 2.73465H20.0036H8.72068L8.48643 2.28148C7.9867 1.31263 6.82327 0.421921 5.67546 0.117203C5.09764 -0.03125 3.87175 -0.03125 3.30174 0.125015ZM5.47244 2.60964C5.60518 2.67996 5.83943 2.86747 5.99559 3.02374C6.98724 4.00821 6.7608 5.62556 5.53491 6.32876C4.55107 6.89132 3.36421 6.58659 2.73174 5.60212C2.09927 4.61765 2.49749 3.18001 3.55161 2.6487C4.13723 2.34399 4.92586 2.32836 5.47244 2.60964ZM36.4634 2.6487C36.8616 2.85185 37.2833 3.32846 37.4551 3.77382C37.6737 4.34418 37.5956 5.10207 37.2755 5.60212C36.3697 7.00851 34.3708 6.92257 33.6134 5.43805C32.9809 4.20354 33.7696 2.63308 35.1438 2.42993C35.5811 2.36743 36.0652 2.44556 36.4634 2.6487ZM31.0835 5.5865C31.4271 7.13353 32.8638 8.57117 34.4099 8.91496L34.8393 9.00871V20.002V30.9953L34.4099 31.089C32.8638 31.4328 31.4271 32.8705 31.0835 34.4175L30.9898 34.8472H20.0036H9.01739L8.92369 34.4175C8.58013 32.8705 7.14341 31.4328 5.59737 31.089L5.16792 30.9953V20.002V9.00871L5.59737 8.91496C7.14341 8.57117 8.58013 7.13353 8.92369 5.5865L9.01739 5.15677H20.0036H30.9898L31.0835 5.5865ZM5.45682 33.6284C6.93259 34.3706 7.01848 36.3864 5.61299 37.285C5.11326 37.6053 4.35586 37.6835 3.78586 37.4647C2.20859 36.8631 1.94311 34.6128 3.34859 33.7456C4.02791 33.3236 4.77751 33.2768 5.45682 33.6284ZM36.4478 33.6362C37.1662 33.9878 37.5722 34.6597 37.5722 35.4879C37.5722 36.738 36.5962 37.6444 35.3234 37.5663C34.8862 37.5428 34.7222 37.4959 34.3942 37.285C33.2464 36.5505 33.0512 35.0269 33.9726 34.0425C34.1756 33.8159 34.6207 33.558 34.9955 33.433C35.3234 33.3236 36.0027 33.4174 36.4478 33.6362Z" />
5
+ <path d="M11 13.1818V15.3636H12.1529H13.3058V14.3506V13.3377H16.095H18.8843V19.961V26.5844H17.9174H16.9504V27.7922V29H20H23.0496V27.7922V26.5844H22.0826H21.1157V19.961V13.3377H23.905H26.6942V14.3506V15.3636H27.8471H29V13.1818V11H20H11V13.1818Z" />
6
+ </svg>
7
+ )
8
+ }
9
+
10
+ export function ColorSvgIcon(props: React.ComponentProps<"svg">) {
11
+ return (
12
+ <svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
13
+ <path d="M27.9997 0.0813332C27.4285 0.210138 26.8574 0.454187 26.3726 0.766029C26.1468 0.908392 24.9448 2.07441 23.331 3.72175L20.6613 6.43341L20.0304 5.82329C19.3198 5.12504 19.1205 5.03013 18.4896 5.0708C17.7126 5.11826 17.2012 5.68771 17.2012 6.49443C17.2012 7.0571 17.3141 7.27403 17.9716 7.97229L18.556 8.58919L13.5354 13.721C10.7727 16.5412 8.46155 18.9613 8.40178 19.0901C8.30216 19.2867 8.26896 19.707 8.18926 21.7001C8.13613 23.0017 8.10293 24.4118 8.11621 24.8253C8.13613 25.5236 8.14942 25.5981 8.32873 25.8557C8.45491 26.0456 8.64086 26.2015 8.87994 26.3235L9.2452 26.4998L12.1407 26.3845C14.9698 26.276 15.0495 26.2693 15.3749 26.1066C15.6339 25.9778 16.7563 24.866 20.6679 20.873L25.6222 15.8022L26.2531 16.4259C26.9172 17.0835 27.1164 17.192 27.6875 17.192C28.4314 17.192 29.0888 16.5005 29.0888 15.7277C29.0888 15.2463 28.9494 15.0023 28.3185 14.3379L27.6942 13.6939L30.158 11.1856C33.1598 8.13499 33.3591 7.89094 33.7708 6.75204C33.9368 6.30461 33.9634 6.12835 33.99 5.25384C34.0165 4.39966 33.9966 4.19629 33.8771 3.74208C33.3989 1.9795 32.084 0.616888 30.3838 0.142345C29.8061 -0.0203533 28.5708 -0.0474701 27.9997 0.0813332ZM29.919 3.1523C30.9815 3.66073 31.4398 4.95556 30.9284 5.97243C30.8022 6.22326 30.0651 7.01642 28.179 8.94171L25.6022 11.5652L24.1412 10.0738L22.6802 8.58241L25.237 5.97243C28.1391 3.00315 28.1989 2.9557 29.049 2.9557C29.4275 2.9557 29.6002 2.99638 29.919 3.1523ZM22.0028 12.2974L23.4572 13.782L18.7818 18.5681L14.1131 23.361L12.5857 23.4152C11.7489 23.4491 11.0516 23.4695 11.0383 23.4627C11.0317 23.4559 11.0516 22.7509 11.0848 21.9035L11.1446 20.3578L15.8199 15.5853C18.3834 12.9685 20.5019 10.8195 20.5218 10.8195C20.5417 10.8195 21.2058 11.4839 22.0028 12.2974Z" />
14
+ <path d="M3.32099 8.08074C2.12559 8.39936 0.989964 9.32811 0.478598 10.4128C-0.0327669 11.4771 0.000438704 10.7382 0.000438704 20.9883C0.000438704 31.2384 -0.0327669 30.4995 0.478598 31.5638C0.863782 32.3705 1.60095 33.123 2.39124 33.5162C3.44053 34.045 2.69673 34.0111 12.8908 33.9907L22.0157 33.9704L22.5005 33.7874C23.2177 33.523 23.676 33.2179 24.2471 32.6485C24.6389 32.2485 24.8116 32.0044 25.0241 31.5638C25.4824 30.6147 25.5023 30.452 25.5023 28.0657C25.5023 26.0116 25.4956 25.9506 25.3495 25.6727C24.9312 24.8592 23.8221 24.6626 23.1381 25.2795C22.6865 25.6795 22.6865 25.693 22.6466 27.8691C22.6134 29.8148 22.6134 29.8419 22.4407 30.1741C22.2282 30.5944 21.8231 30.9198 21.3848 31.0147C20.9066 31.1164 4.59608 31.1164 4.11792 31.0147C3.67961 30.9198 3.2745 30.5944 3.06199 30.1741L2.88932 29.8419V20.9544C2.88932 12.4194 2.89596 12.0669 3.0155 11.8364C3.18817 11.5042 3.44053 11.2398 3.75266 11.0704C4.00503 10.928 4.13121 10.9212 5.97743 10.8873C8.16236 10.8466 8.169 10.8466 8.56082 10.3653C8.9128 9.93146 8.99914 9.32133 8.77334 8.81967C8.64716 8.52817 8.25533 8.14176 7.98969 8.03329C7.71076 7.92482 3.74602 7.9655 3.32099 8.08074Z" />
15
+ </svg>
16
+
17
+ )
18
+ }
19
+
20
+ export function SizeSvgIcon(props: React.ComponentProps<"svg">) {
21
+ return (
22
+ <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
23
+ <path d="M3.76043 0.125106C2.37341 0.487652 1.16757 1.45653 0.542786 2.71919C0.0367102 3.72557 0.0117188 3.93184 0.0117188 6.58219V8.89498L0.167915 9.16377C0.399086 9.55757 0.761461 9.75134 1.26129 9.75134C1.76112 9.75134 2.12349 9.55757 2.35466 9.16377L2.51086 8.89498V7.02599C2.51086 4.98198 2.56084 4.45691 2.79201 3.95685C2.9857 3.53179 3.54176 2.97547 3.96661 2.7817C4.46644 2.55042 4.99126 2.50041 7.03431 2.50041H8.90241L9.17107 2.34414C9.56469 2.11286 9.75837 1.75031 9.75837 1.25025C9.75837 0.750186 9.56469 0.387639 9.17107 0.15636L8.90241 8.96454e-05L6.56572 0.00634003C4.46644 0.00634003 4.17904 0.0188417 3.76043 0.125106Z" />
24
+ <path d="M12.8369 0.162566C12.4495 0.393846 12.2559 0.756392 12.2559 1.25021C12.2559 1.75027 12.4495 2.11282 12.8432 2.3441L13.1118 2.50037H16.0046H18.8973L19.166 2.3441C19.5596 2.11282 19.7533 1.75027 19.7533 1.25021C19.7533 0.750141 19.5596 0.387595 19.166 0.156315L18.8973 4.50611e-05H15.9983H13.0993L12.8369 0.162566Z" />
25
+ <path d="M22.835 0.16261C22.4476 0.39389 22.2539 0.756436 22.2539 1.25025C22.2539 1.75031 22.4476 2.11286 22.8412 2.34414L23.1099 2.50041H24.978C27.021 2.50041 27.5458 2.55042 28.0457 2.7817C28.4705 2.97547 29.0266 3.53179 29.2203 3.95685C29.4514 4.45691 29.5014 4.98198 29.5014 7.02599V8.89498L29.6576 9.16377C29.8888 9.55757 30.2512 9.75134 30.751 9.75134C31.2508 9.75134 31.6132 9.55757 31.8444 9.16377L32.0006 8.89498V6.58219C32.0006 3.91934 31.9756 3.72557 31.457 2.68793C31.2071 2.18162 31.0696 2.00035 30.5323 1.46278C29.9887 0.918958 29.8263 0.793941 29.314 0.537659C28.2643 0.0250931 28.0832 8.96454e-05 25.4153 8.96454e-05H23.0974L22.835 0.16261Z" />
26
+ <path d="M21.3342 5.16316C20.9469 5.39444 20.7532 5.75698 20.7532 6.2508C20.7532 6.75086 20.9469 7.11341 21.3405 7.34469C21.5716 7.47595 21.6841 7.50096 22.134 7.50096H22.6588L16.2235 13.9393L9.78819 20.3776L9.75695 19.8338C9.71947 19.1775 9.57577 18.8962 9.16966 18.6586C8.96348 18.5336 8.81353 18.5024 8.50738 18.5024C8.00756 18.5024 7.64518 18.6961 7.41401 19.0899L7.25781 19.3587V21.6278V23.8968L7.41401 24.1656C7.50773 24.3281 7.68267 24.5031 7.84511 24.5969L8.11377 24.7532H10.3817H12.6497L12.9184 24.5969C13.6931 24.1406 13.7181 22.9029 12.9621 22.4279C12.7747 22.3154 12.606 22.2779 12.1749 22.2529L11.6313 22.2216L18.0666 15.7833L24.5019 9.34494V9.87001C24.5019 10.3201 24.5269 10.4326 24.6581 10.6639C24.8893 11.0577 25.2516 11.2514 25.7515 11.2514C26.2513 11.2514 26.6137 11.0577 26.8448 10.6639L27.001 10.3951V8.12604V5.85699L26.8448 5.58821C26.7511 5.42569 26.5762 5.25067 26.4137 5.15691L26.1451 5.00064H23.8709H21.5966L21.3342 5.16316Z" />
27
+ <path d="M2.7288 12.3704C1.44799 12.7454 0.435839 13.7768 0.104702 15.0457C-0.0389982 15.5958 -0.0327503 28.6537 0.11095 29.21C0.442087 30.4977 1.51672 31.5729 2.79753 31.9041C3.14116 31.9917 3.97212 32.0042 9.88259 32.0042C17.3175 32.0042 16.9052 32.0229 17.7799 31.5854C18.3797 31.2853 19.0357 30.629 19.3356 30.0289C19.7667 29.1788 19.7542 29.3038 19.7542 24.5282C19.7542 20.3339 19.7542 20.2214 19.6292 19.9651C19.4356 19.5776 19.0857 19.3588 18.5921 19.3275C18.086 19.2963 17.6987 19.465 17.455 19.8338L17.2863 20.0901L17.2551 24.3094C17.2238 28.4787 17.2238 28.5412 17.0926 28.785C16.9364 29.0725 16.8115 29.1913 16.5053 29.3538C16.2929 29.4663 16.018 29.4726 9.88259 29.4726C3.7472 29.4726 3.4723 29.4663 3.25987 29.3538C2.95372 29.1913 2.82877 29.0725 2.67257 28.785L2.54137 28.535V22.1279C2.54137 15.9896 2.54761 15.7146 2.66007 15.502C2.82252 15.1957 2.94123 15.0707 3.22863 14.9145C3.4723 14.7832 3.53477 14.7832 7.70209 14.7519L11.9194 14.7207L12.1443 14.5707C12.5379 14.2956 12.6629 14.0456 12.6629 13.5018C12.6629 13.0767 12.6441 12.9955 12.4817 12.7642C12.3817 12.6204 12.1943 12.4516 12.0444 12.3766C11.7882 12.2516 11.682 12.2516 7.43968 12.2579C3.50354 12.2579 3.06619 12.2704 2.7288 12.3704Z" />
28
+ <path d="M30.0811 12.4141C29.9311 12.5016 29.7437 12.6892 29.6562 12.8392L29.5 13.108V16.0021V18.8962L29.6562 19.165C29.8874 19.5588 30.2497 19.7526 30.7496 19.7526C31.2494 19.7526 31.6118 19.5588 31.8429 19.165L31.9991 18.8962V16.0021V13.108L31.8429 12.8392C31.6118 12.4454 31.2494 12.2516 30.7433 12.2516C30.4247 12.2516 30.2872 12.2829 30.0811 12.4141Z" />
29
+ <path d="M30.0825 22.4154C29.9325 22.5029 29.7451 22.6905 29.6576 22.8405L29.5014 23.1093V24.9782C29.5014 27.0223 29.4514 27.5473 29.2203 28.0474C29.0266 28.4724 28.4705 29.0288 28.0457 29.2225C27.5458 29.4538 27.021 29.5038 24.978 29.5038H23.1099L22.8412 29.6601C22.4476 29.8914 22.2539 30.2539 22.2539 30.754C22.2539 31.2541 22.4476 31.6166 22.8412 31.8479L23.1099 32.0042H25.4216C28.0832 32.0042 28.2768 31.9791 29.314 31.4603C29.8201 31.2103 29.995 31.0728 30.5323 30.5352C31.0696 29.9976 31.2071 29.8226 31.457 29.3163C31.9756 28.2787 32.0006 28.0849 32.0006 25.4221V23.1093L31.8444 22.8405C31.6132 22.4467 31.2508 22.2529 30.7447 22.2529C30.4261 22.2529 30.2886 22.2842 30.0825 22.4154Z" />
30
+ </svg>
31
+
32
+ )
33
+ }
34
+
35
+ export function TemplateSvgIcon(props: React.ComponentProps<"svg">) {
36
+ return (
37
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
38
+ <rect x="4" y="5" width="28" height="26" rx="3" stroke="currentColor" strokeWidth="2.6" />
39
+ <path d="M4 13H32" stroke="currentColor" strokeWidth="2.6" />
40
+ <path d="M14 13V31" stroke="currentColor" strokeWidth="2.6" />
41
+ <rect x="8.5" y="8.5" width="3" height="2.8" rx="0.8" fill="currentColor" />
42
+ <rect x="16.7" y="8.5" width="3" height="2.8" rx="0.8" fill="currentColor" />
43
+ </svg>
44
+ )
45
+ }
46
+
47
+ export function ShapeSvgIcon(props: React.ComponentProps<"svg">) {
48
+ return (
49
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
50
+ <rect x="4" y="8" width="28" height="10" rx="2" stroke="currentColor" strokeWidth="2.6" />
51
+ <rect x="9" y="21" width="18" height="11" rx="2" stroke="currentColor" strokeWidth="2.6" />
52
+ </svg>
53
+ )
54
+ }
@@ -0,0 +1,252 @@
1
+ import React from "react"
2
+
3
+ import { useCallback, useRef, useState } from "react"
4
+ import {
5
+ Dialog,
6
+ DialogPortal,
7
+ DialogOverlay,
8
+ DialogTitle,
9
+ } from "./ui/dialog"
10
+ import * as DialogPrimitive from "@radix-ui/react-dialog"
11
+ import { X, HelpCircle, FileUp, LayoutPanelTop } from "lucide-react"
12
+ import { cn, downloadCsv, parseCsvFile } from "../lib/utils"
13
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
14
+ import { isTextLayer } from "../lib/type-checks"
15
+ import { useLayersStore } from "../store/layers-store"
16
+ import type { LayrProps, TLayer } from "./preview"
17
+ import { useCartStore } from "../store/cart-store"
18
+
19
+ interface ImportFileModalProps {
20
+ open: boolean
21
+ onOpenChange: (open: boolean) => void
22
+ }
23
+
24
+ export function ImportFileModal({
25
+ open,
26
+ onOpenChange,
27
+ }: ImportFileModalProps) {
28
+ const [isDragging, setIsDragging] = useState(false)
29
+ const [file, setFile] = useState<File | null>(null)
30
+ const inputRef = useRef<HTMLInputElement>(null)
31
+
32
+ const { layers, options } = useLayersStore();
33
+ const { add } = useCartStore()
34
+
35
+ const handleDragOver = useCallback((e: React.DragEvent) => {
36
+ e.preventDefault()
37
+ setIsDragging(true)
38
+ }, [])
39
+
40
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
41
+ e.preventDefault()
42
+ setIsDragging(false)
43
+ }, [])
44
+
45
+ const handleDrop = useCallback((e: React.DragEvent) => {
46
+ e.preventDefault()
47
+ setIsDragging(false)
48
+ const droppedFile = e.dataTransfer.files[0]
49
+ if (droppedFile) {
50
+ setFile(droppedFile)
51
+ uploadImport(droppedFile)
52
+ }
53
+ }, [])
54
+
55
+ // const handleFileSelect = useCallback(
56
+ // (e: React.ChangeEvent<HTMLInputElement>) => {
57
+ // const selectedFile = e.target.files?.[0]
58
+ // if (selectedFile) {
59
+ // setFile(selectedFile)
60
+ // }
61
+ // },
62
+ // []
63
+ // )
64
+
65
+ const downloadTemplate = () => {
66
+ const exampleObject: Record<string, string> = {};
67
+ let textNumber = 1;
68
+ for (const layer of layers) {
69
+ if (layer.type === 'text' && layer.subtype !== 'braille' && isTextLayer(layer)) {
70
+ exampleObject[`text_${textNumber}`] = layer.props.text;
71
+ textNumber += 1;
72
+ }
73
+ }
74
+
75
+ downloadCsv([exampleObject], 'template.csv')
76
+ }
77
+
78
+ const handleChooseFile = useCallback(() => {
79
+ inputRef.current?.click()
80
+ }, [])
81
+
82
+ const uploadImport = async (file: File | undefined) => {
83
+ if (!file) return;
84
+
85
+ setFile(file);
86
+
87
+ const data = await parseCsvFile<Record<`text_${number}`, string>>(file);
88
+
89
+ const defaultLayers = layers.filter(layer => layer.type !== 'text' && layer.subtype !== 'braille');
90
+ const textLayers = layers.filter(layer => layer.type === 'text' && layer.subtype !== 'braille');
91
+
92
+ // Створення нових наборів шарів в картці
93
+ for (const row of data) {
94
+ const newTextLayers: TLayer<LayrProps>[] = []
95
+ for (const [textKey, textValue] of Object.entries(row)) {
96
+ const textNumber = Number(textKey.replace('text_', ''));
97
+
98
+ if (!textLayers[textNumber - 1]) break;
99
+
100
+ const textDefault = textLayers[textNumber - 1]
101
+ newTextLayers.push({
102
+ ...textDefault,
103
+ props: {
104
+ ...textDefault.props,
105
+ text: textValue
106
+ }
107
+ });
108
+ }
109
+
110
+ add([...defaultLayers, ...newTextLayers], '', options);
111
+ }
112
+
113
+ onOpenChange(false);
114
+ }
115
+
116
+ return (
117
+ <Dialog open={open} onOpenChange={onOpenChange}>
118
+ <DialogPortal>
119
+ <DialogOverlay className="bg-black/40" />
120
+ <DialogPrimitive.Content
121
+ className={cn(
122
+ "fixed left-1/2 top-1/2 z-50 w-[calc(100%-1rem)] max-w-[580px] -translate-x-1/2 -translate-y-1/2 sm:w-full",
123
+ "rounded-2xl border border-border bg-background shadow-xl",
124
+ "duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out",
125
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
126
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
127
+ "data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
128
+ "data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
129
+ "focus:outline-none"
130
+ )}
131
+ >
132
+ {/* Header */}
133
+ <div className="flex items-center justify-between px-4 pt-4 pb-3 sm:px-6 sm:pt-5 sm:pb-4">
134
+ {/* <button
135
+ onClick={onBack}
136
+ className="flex items-center gap-2 text-sm font-medium text-foreground hover:text-foreground/80 transition-colors"
137
+ >
138
+ <ArrowLeft className="h-4 w-4" />
139
+ Back
140
+ </button> */}
141
+ <DialogTitle className="text-base font-semibold text-foreground">
142
+ Import file
143
+ </DialogTitle>
144
+ <DialogPrimitive.Close className="flex items-center gap-2 text-sm font-medium text-foreground hover:text-foreground/80 transition-colors">
145
+ <X className="h-4 w-4" />
146
+ Close
147
+ </DialogPrimitive.Close>
148
+ </div>
149
+
150
+ {/* Drop Zone */}
151
+ <div className="px-4 sm:px-6">
152
+ <div
153
+ onDragOver={handleDragOver}
154
+ onDragLeave={handleDragLeave}
155
+ onDrop={handleDrop}
156
+ className={cn(
157
+ "flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-10 transition-colors sm:py-14",
158
+ isDragging
159
+ ? "border-[#2e7d32] bg-[#e8f5e9]"
160
+ : "border-[#4caf50]/50 bg-[#f1f8e9]/60",
161
+ file && "border-[#2e7d32] bg-[#e8f5e9]"
162
+ )}
163
+ >
164
+ {file ? (
165
+ <>
166
+ <div className="mb-3 flex h-14 w-14 items-center justify-center rounded-lg bg-[#2e7d32]/10">
167
+ <FileUp className="h-8 w-8 text-[#2e7d32]" />
168
+ </div>
169
+ <p className="text-sm text-foreground font-medium">
170
+ {file.name}
171
+ </p>
172
+ <button
173
+ onClick={() => setFile(null)}
174
+ className="mt-1 text-xs text-muted-foreground underline hover:text-foreground"
175
+ >
176
+ Remove
177
+ </button>
178
+ </>
179
+ ) : (
180
+ <>
181
+ <div className="mb-3 flex h-14 w-14 items-center justify-center rounded-lg bg-[#2e7d32]/10">
182
+ <FileUp className="h-8 w-8 text-[#2e7d32]" />
183
+ </div>
184
+ <p className="text-sm text-foreground">
185
+ {"Drag and Drop file here or "}
186
+ <button
187
+ onClick={handleChooseFile}
188
+ className="font-semibold underline underline-offset-2 hover:text-foreground/80"
189
+ >
190
+ Choose file
191
+ </button>
192
+ </p>
193
+ </>
194
+ )}
195
+ <input
196
+ ref={inputRef}
197
+ type="file"
198
+ accept=".csv"
199
+ // onChange={handleFileSelect}
200
+ onChange={e => uploadImport(e.target.files?.[0])}
201
+ className="hidden"
202
+ aria-label="Choose file to upload"
203
+ />
204
+ </div>
205
+
206
+ {/* Supported Formats */}
207
+ <div className="flex items-center justify-between py-3 text-xs text-muted-foreground">
208
+ <span>Supported format: CSV</span>
209
+ <span>Maximum size: 25MB</span>
210
+ </div>
211
+ </div>
212
+
213
+ {/* Divider */}
214
+ <div className="border-t border-border" />
215
+
216
+ {/* Footer */}
217
+ <div className="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
218
+
219
+ <Tooltip>
220
+ <TooltipTrigger>
221
+ <button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
222
+ <HelpCircle className="h-4 w-4" />
223
+ Help
224
+ </button>
225
+ </TooltipTrigger>
226
+ <TooltipContent className="max-w-[250px]">
227
+ You can upload a ready-made CSV file to create multiple signs at once. Use the template to quickly generate several tables. This is especially useful if your signs contains multiple text rows or has a complex structure.
228
+ </TooltipContent>
229
+ </Tooltip>
230
+ <button onClick={() => downloadTemplate()} className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
231
+ <LayoutPanelTop className="h-4 w-4" />
232
+ Template
233
+ </button>
234
+ <div className="flex w-full items-center gap-3 sm:w-auto">
235
+ <button
236
+ onClick={() => onOpenChange(false)}
237
+ className="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-sm font-medium text-foreground hover:bg-accent transition-colors sm:w-auto sm:px-6"
238
+ >
239
+ Cancel
240
+ </button>
241
+ <button
242
+ className="w-full rounded-lg bg-foreground px-4 py-2.5 text-sm font-medium text-background hover:bg-foreground/90 transition-colors sm:w-auto sm:px-6"
243
+ >
244
+ Next
245
+ </button>
246
+ </div>
247
+ </div>
248
+ </DialogPrimitive.Content>
249
+ </DialogPortal>
250
+ </Dialog>
251
+ )
252
+ }
@@ -0,0 +1,29 @@
1
+ export default function GridView({ w, h, color, width = 1 }: {
2
+ w: number;
3
+ h: number;
4
+ color: string;
5
+ width?: number;
6
+ }) {
7
+ const columns = Math.max(1, Math.ceil(w));
8
+ const rows = Math.max(1, Math.ceil(h));
9
+
10
+ return (
11
+ <div
12
+ className="grid h-full w-full"
13
+ style={{
14
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
15
+ gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
16
+ outlineColor: color,
17
+ outlineWidth: width * 2
18
+ }}
19
+ >
20
+ {Array.from({ length: columns * rows }).map((_, i) => (
21
+ <div
22
+ className="outline-1"
23
+ key={i}
24
+ style={{ outlineColor: color, outlineWidth: width }}
25
+ />
26
+ ))}
27
+ </div>
28
+ )
29
+ }