nitro-web 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 (152) hide show
  1. package/.editorconfig +9 -0
  2. package/.eslintrc.json +86 -0
  3. package/_example/.env-example +16 -0
  4. package/_example/client/config.ts +5 -0
  5. package/_example/client/css/index.css +35 -0
  6. package/_example/client/fonts/Roboto-Bold.ttf +0 -0
  7. package/_example/client/fonts/Roboto-BoldItalic.ttf +0 -0
  8. package/_example/client/fonts/Roboto-Italic.ttf +0 -0
  9. package/_example/client/fonts/Roboto-Medium.ttf +0 -0
  10. package/_example/client/fonts/Roboto-MediumItalic.ttf +0 -0
  11. package/_example/client/fonts/Roboto-Regular.ttf +0 -0
  12. package/_example/client/fonts/inter-v13-latin-300.woff2 +0 -0
  13. package/_example/client/fonts/inter-v13-latin-500.woff2 +0 -0
  14. package/_example/client/fonts/inter-v13-latin-600.woff2 +0 -0
  15. package/_example/client/fonts/inter-v13-latin-700.woff2 +0 -0
  16. package/_example/client/fonts/inter-v13-latin-800.woff2 +0 -0
  17. package/_example/client/fonts/inter-v13-latin-900.woff2 +0 -0
  18. package/_example/client/fonts/inter-v13-latin-regular.woff2 +0 -0
  19. package/_example/client/imgs/android-chrome-512x512.png +0 -0
  20. package/_example/client/imgs/favicon.png +0 -0
  21. package/_example/client/imgs/icons/calendar.svg +3 -0
  22. package/_example/client/imgs/icons/email.svg +6 -0
  23. package/_example/client/imgs/icons/eye-open.svg +4 -0
  24. package/_example/client/imgs/icons/eye.svg +5 -0
  25. package/_example/client/imgs/icons/filter.svg +7 -0
  26. package/_example/client/imgs/icons/left-circle.svg +3 -0
  27. package/_example/client/imgs/icons/left.svg +3 -0
  28. package/_example/client/imgs/icons/line-options.svg +5 -0
  29. package/_example/client/imgs/icons/line.svg +3 -0
  30. package/_example/client/imgs/icons/person.svg +7 -0
  31. package/_example/client/imgs/icons/plus-circle.svg +5 -0
  32. package/_example/client/imgs/icons/plus.svg +5 -0
  33. package/_example/client/imgs/icons/right-circle.svg +3 -0
  34. package/_example/client/imgs/icons/right.svg +3 -0
  35. package/_example/client/imgs/icons/search.svg +3 -0
  36. package/_example/client/imgs/icons/shield.svg +6 -0
  37. package/_example/client/imgs/icons/tick-circle-solid.svg +8 -0
  38. package/_example/client/imgs/icons/tick-circle.svg +6 -0
  39. package/_example/client/imgs/icons/tick.svg +5 -0
  40. package/_example/client/imgs/icons/up2-small.svg +4 -0
  41. package/_example/client/imgs/icons/up2.svg +4 -0
  42. package/_example/client/imgs/icons/updown.svg +6 -0
  43. package/_example/client/imgs/icons/v-big-dark.svg +3 -0
  44. package/_example/client/imgs/icons/v-dark.svg +3 -0
  45. package/_example/client/imgs/icons/v.svg +3 -0
  46. package/_example/client/imgs/icons/v2-active.svg +6 -0
  47. package/_example/client/imgs/icons/x1.svg +4 -0
  48. package/_example/client/imgs/logo/logo-white.svg +20 -0
  49. package/_example/client/imgs/logo/logo.svg +20 -0
  50. package/_example/client/imgs/no-image.jpg +0 -0
  51. package/_example/client/imgs/user.jpg +0 -0
  52. package/_example/client/index.html +12 -0
  53. package/_example/client/index.ts +47 -0
  54. package/_example/components/auth.api.js +1 -0
  55. package/_example/components/index.tsx +225 -0
  56. package/_example/components/partials/layouts.tsx +5 -0
  57. package/_example/components/settings.api.js +1 -0
  58. package/_example/server/config.js +120 -0
  59. package/_example/server/email/welcome.html +27 -0
  60. package/_example/server/index.js +32 -0
  61. package/_example/tailwind.config.js +84 -0
  62. package/_example/tsconfig.json +32 -0
  63. package/_example/types.d.ts +7 -0
  64. package/_example/webpack.config.js +4 -0
  65. package/client/app.js +300 -0
  66. package/client/css/components.css +84 -0
  67. package/client/css/fonts.css +67 -0
  68. package/client/imgs/icons/calendar.svg +3 -0
  69. package/client/imgs/icons/email.svg +6 -0
  70. package/client/imgs/icons/eye-open.svg +4 -0
  71. package/client/imgs/icons/eye.svg +5 -0
  72. package/client/imgs/icons/filter.svg +7 -0
  73. package/client/imgs/icons/left-circle.svg +3 -0
  74. package/client/imgs/icons/left.svg +3 -0
  75. package/client/imgs/icons/line-options.svg +5 -0
  76. package/client/imgs/icons/line.svg +3 -0
  77. package/client/imgs/icons/person.svg +7 -0
  78. package/client/imgs/icons/plus-circle.svg +5 -0
  79. package/client/imgs/icons/plus.svg +5 -0
  80. package/client/imgs/icons/right-circle.svg +3 -0
  81. package/client/imgs/icons/right.svg +3 -0
  82. package/client/imgs/icons/search.svg +3 -0
  83. package/client/imgs/icons/shield.svg +6 -0
  84. package/client/imgs/icons/tick-circle-solid.svg +8 -0
  85. package/client/imgs/icons/tick-circle.svg +6 -0
  86. package/client/imgs/icons/tick.svg +5 -0
  87. package/client/imgs/icons/up2-small.svg +4 -0
  88. package/client/imgs/icons/up2.svg +4 -0
  89. package/client/imgs/icons/updown.svg +6 -0
  90. package/client/imgs/icons/v-big-dark.svg +3 -0
  91. package/client/imgs/icons/v-dark.svg +3 -0
  92. package/client/imgs/icons/v.svg +3 -0
  93. package/client/imgs/icons/v2-active.svg +6 -0
  94. package/client/imgs/icons/x1.svg +4 -0
  95. package/client.js +42 -0
  96. package/components/auth/auth.api.js +419 -0
  97. package/components/auth/reset.jsx +88 -0
  98. package/components/auth/signin.jsx +74 -0
  99. package/components/auth/signup.jsx +62 -0
  100. package/components/billing/stripe.api.js +267 -0
  101. package/components/partials/element/accordion.jsx +82 -0
  102. package/components/partials/element/avatar.jsx +28 -0
  103. package/components/partials/element/button.jsx +66 -0
  104. package/components/partials/element/dropdown.jsx +185 -0
  105. package/components/partials/element/initials.jsx +56 -0
  106. package/components/partials/element/message.jsx +124 -0
  107. package/components/partials/element/modal.jsx +229 -0
  108. package/components/partials/element/sidebar.jsx +166 -0
  109. package/components/partials/element/tooltip.jsx +146 -0
  110. package/components/partials/element/topbar.jsx +25 -0
  111. package/components/partials/form/checkbox.jsx +74 -0
  112. package/components/partials/form/drop-handler.jsx +62 -0
  113. package/components/partials/form/drop.jsx +125 -0
  114. package/components/partials/form/form-error.jsx +21 -0
  115. package/components/partials/form/input-color.jsx +77 -0
  116. package/components/partials/form/input-currency.jsx +133 -0
  117. package/components/partials/form/input-date.jsx +223 -0
  118. package/components/partials/form/input.jsx +131 -0
  119. package/components/partials/form/location.jsx +212 -0
  120. package/components/partials/form/select.jsx +369 -0
  121. package/components/partials/form/toggle.jsx +46 -0
  122. package/components/partials/is-first-render.js +15 -0
  123. package/components/partials/layout/layout1.jsx +32 -0
  124. package/components/partials/layout/layout2.jsx +47 -0
  125. package/components/partials/not-found.jsx +7 -0
  126. package/components/partials/styleguide.jsx +252 -0
  127. package/components/settings/settings-account.jsx +143 -0
  128. package/components/settings/settings-business.jsx +121 -0
  129. package/components/settings/settings-team--member.jsx +108 -0
  130. package/components/settings/settings-team.jsx +76 -0
  131. package/components/settings/settings.api.js +54 -0
  132. package/package.json +175 -0
  133. package/readme.md +43 -0
  134. package/server/email/index.js +192 -0
  135. package/server/email/partials/email.css +153 -0
  136. package/server/email/partials/layout1.swig +92 -0
  137. package/server/email/partials/line.swig +8 -0
  138. package/server/email/partials/vert-10.swig +8 -0
  139. package/server/email/partials/vert-15.swig +8 -0
  140. package/server/email/partials/vert-20.swig +8 -0
  141. package/server/email/partials/vert-25.swig +8 -0
  142. package/server/email/partials/vert-30.swig +8 -0
  143. package/server/email/partials/vert-35.swig +8 -0
  144. package/server/email/partials/vert-50.swig +8 -0
  145. package/server/email/reset-password.html +21 -0
  146. package/server/email/welcome.html +21 -0
  147. package/server/models/company.js +76 -0
  148. package/server/models/user.js +45 -0
  149. package/server/router.js +355 -0
  150. package/server.js +20 -0
  151. package/util.js +1145 -0
  152. package/webpack.config.js +302 -0
@@ -0,0 +1,74 @@
1
+
2
+ /**
3
+ * Checkbox/radio/toggle component
4
+ * @param {string} name - The name of the checkbox
5
+ * @param {string} [id] - The id of the checkbox (used for radios)
6
+ * @param {'sm' | 'md'} [size='sm'] - The size of the toggle
7
+ * @param {string} [subtext]
8
+ * @param {string} [text]
9
+ * @param {'checkbox' | 'radio' | 'toggle'} [type='checkbox']
10
+ * @param {object} [props] - input props
11
+ *
12
+ * @link https://tailwindui.com/components/application-ui/forms/checkboxes#component-744ed4fa65ba36b925701eb4da5c6e31
13
+ */
14
+ export function Checkbox({ name, id, size='sm', subtext, text, type='checkbox', ...props }) {
15
+ if (!name) throw new Error('Checkbox requires a `name` prop')
16
+ id = id || name
17
+ return (
18
+ <div className={`mt-input-before mb-input-after flex gap-3 ${props.className || ''}`}>
19
+ <div className="flex h-6 shrink-0 items-center">
20
+ {
21
+ type !== 'toggle'
22
+ ? <div className="group grid size-4 grid-cols-1">
23
+ <input
24
+ {...props}
25
+ id={id}
26
+ name={name}
27
+ type={type}
28
+ className="col-start-1 row-start-1 appearance-none rounded border border-gray-300 bg-white checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
29
+ />
30
+ <svg
31
+ fill="none"
32
+ viewBox="0 0 14 14"
33
+ className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-[:disabled]:stroke-gray-950/25"
34
+ >
35
+ <path
36
+ d="M3 8L6 11L11 3.5"
37
+ strokeWidth={2}
38
+ strokeLinecap="round"
39
+ strokeLinejoin="round"
40
+ className="opacity-0 group-has-[:checked]:opacity-100"
41
+ />
42
+ <path
43
+ d="M3 7H11"
44
+ strokeWidth={2}
45
+ strokeLinecap="round"
46
+ strokeLinejoin="round"
47
+ className="opacity-0 group-has-[:indeterminate]:opacity-100"
48
+ />
49
+ </svg>
50
+ </div>
51
+ : <div className="group grid grid-cols-1">
52
+ <input
53
+ {...props}
54
+ id={id}
55
+ name={name}
56
+ type="checkbox"
57
+ class="sr-only peer"
58
+ />
59
+ <label
60
+ for={id}
61
+ className={`col-start-1 row-start-1 relative ${size == 'sm' ? 'w-9' : 'w-11'} ${size == 'sm' ? 'h-5' : 'h-6'} bg-gray-200 peer-focus-visible:outline-none peer-focus-visible:ring-4 peer-focus-visible:ring-blue-300 dark:peer-focus-visible:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full ${size == 'sm' ? 'after:w-4' : 'after:w-5'} ${size == 'sm' ? 'after:h-4' : 'after:h-5'} after:transition-all dark:border-gray-600 peer-checked:bg-blue-600`}
62
+ />
63
+ </div>
64
+ }
65
+ </div>
66
+ {text && <div className="text-sm/6">
67
+ <label for={id} className="select-none">
68
+ <span className="font-medium text-gray-900">{text}</span>
69
+ <span className="ml-2 text-gray-500">{subtext}</span>
70
+ </label>
71
+ </div>}
72
+ </div>
73
+ )
74
+ }
@@ -0,0 +1,62 @@
1
+ export const DropHandler = ({ onDrop, children, className }) => {
2
+ const dropRef = useRef()
3
+ let dragCounter = useRef(0).current
4
+ const [dragging, setDragging] = useState(false)
5
+
6
+ useEffect(() => {
7
+ let div = dropRef.current
8
+ div.addEventListener('dragenter', handleDragIn)
9
+ div.addEventListener('dragleave', handleDragOut)
10
+ div.addEventListener('dragover', handleDragOver)
11
+ div.addEventListener('drop', handleDrop)
12
+ return () => {
13
+ div.removeEventListener('dragenter', handleDragIn)
14
+ div.removeEventListener('dragleave', handleDragOut)
15
+ div.removeEventListener('dragover', handleDragOver)
16
+ div.removeEventListener('drop', handleDrop)
17
+ }
18
+ }, [])
19
+
20
+ const handleDragIn = (e) => {
21
+ e.preventDefault()
22
+ e.stopPropagation()
23
+ dragCounter++
24
+ if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
25
+ setDragging(true)
26
+ }
27
+ }
28
+
29
+ const handleDragOut = (e) => {
30
+ e.preventDefault()
31
+ e.stopPropagation()
32
+ dragCounter--
33
+ if (dragCounter === 0) {
34
+ setDragging(false)
35
+ }
36
+ }
37
+
38
+ const handleDragOver = (e) => {
39
+ e.preventDefault()
40
+ e.stopPropagation()
41
+ }
42
+
43
+ const handleDrop = (e) => {
44
+ e.preventDefault()
45
+ e.stopPropagation()
46
+ setDragging(false)
47
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
48
+ onDrop(e.dataTransfer.files)
49
+ // e.dataTransfer.clearData() // causes an error in firefox
50
+ dragCounter = 0
51
+ }
52
+ }
53
+
54
+ return (
55
+ <div
56
+ ref={dropRef}
57
+ class={`${className} relative w-full p-[20px] border border-dashed border-input-border border-2 rounded-md ${dragging ? 'border-primary before:content-[""] before:absolute before:inset-0 before:bg-primary before:opacity-5' : ''}`}
58
+ >
59
+ {children}
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,125 @@
1
+ import { isRegex, deepFind, s3Image } from '../../../util.js'
2
+ import { DropHandler } from './drop-handler.jsx'
3
+
4
+ export function Drop({ awsUrl, className, id, name, onChange, multiple, state, ...props }) {
5
+ /**
6
+ * @param {string} name - field name or path on state (used to match errors), e.g. 'avatar', 'company.avatar'
7
+ * @param {string} <id> - not required, name used if not provided
8
+ * @param {function} onChange({ target: { id: <{name}|errors>, value } }) - gets called on success/error
9
+ * @param {object} state - State object to get the value, and check errors against
10
+ */
11
+ if (!name) throw new Error('Drop component requires a `name` prop')
12
+ const inputId = id ||name
13
+ const stateRef = useRef()
14
+ const [urls, setUrls] = useState([])
15
+ stateRef.current = state
16
+
17
+ // Input is always controlled if state is passed in
18
+ if (props.value) {
19
+ var value = props.value
20
+ } else if (typeof state == 'object') {
21
+ value = deepFind(state, name)
22
+ if (typeof value == 'undefined') value = null
23
+ }
24
+
25
+ // An error matches this input path
26
+ for (let item of (state?.errors || [])) {
27
+ if (isRegex(name) && (item.title||'').match(name)) var error = item
28
+ else if (item.title == name) error = item
29
+ }
30
+
31
+ useEffect(() => {
32
+ (async () => setUrls(await getUrls(value)))()
33
+ }, [value])
34
+
35
+ function tryAgain (e) {
36
+ e.preventDefault()
37
+ // clear file input to allow reupload
38
+ document.getElementById(name).value = ''
39
+ if (onChange) {
40
+ const errors = (stateRef.errors||[]).filter(e => e.title != name)
41
+ onChange({
42
+ // remove file from state
43
+ target: { id: name, value: null },
44
+ // reset (server) errors
45
+ errors: errors.length ? errors : undefined,
46
+ })
47
+ }
48
+ }
49
+
50
+ async function onFileAttach (files=[]) {
51
+ // files is a FileList object
52
+ if (onChange) onChange({ target: { id: name, value: multiple ? files : files[0] } })
53
+ }
54
+
55
+ async function getUrls(objectOrFileListItem) {
56
+ /**
57
+ * @param {object|FileList} objectOrFileListItem - FileList object or monastery image object
58
+ * @returns {Promise} - Resolves to an array of image URLs
59
+ */
60
+ // Make sure FileLists are converted to a real array
61
+ if (!objectOrFileListItem) return []
62
+ const array = objectOrFileListItem.length ? Array.from(objectOrFileListItem) : [objectOrFileListItem]
63
+ return Promise.all(array.map((file) => {
64
+ return new Promise((resolve, reject) => {
65
+ if (file.lastModified) {
66
+ const reader = new FileReader()
67
+ reader.onload = () => resolve(reader.result)
68
+ reader.onerror = reject
69
+ reader.readAsDataURL(file)
70
+ } else {
71
+ resolve(s3Image(awsUrl, file))
72
+ }
73
+ })
74
+ }))
75
+ }
76
+
77
+ // function getFilename (objectOrFile) {
78
+ // if (objectOrFile.lastModified) return objectOrFile.name
79
+ // else return 'avatar.jpg'
80
+ // }
81
+
82
+ return (
83
+ <div class={`mt-input-before mb-input-after ${className || ''}`}>
84
+ <input
85
+ {...props}
86
+ id={inputId}
87
+ type="file"
88
+ onChange={(e) => onFileAttach(e.target.files)}
89
+ hidden
90
+ />
91
+ <DropHandler
92
+ onDrop={onFileAttach}
93
+ class="flex flex-column justify-center items-center text-center gap-2 text-grey-300 text-sm px-8 min-h-[300px]"
94
+ >
95
+ {
96
+ !value &&
97
+ <>
98
+ {/* {todo upload svg here} */}
99
+ <div>
100
+ Drag and drop your file here&nbsp;
101
+ <label class="weight-500 inline-block text-sm text-primary" for={inputId}>or select a file</label>
102
+ </div>
103
+ </>
104
+ }
105
+ {
106
+ value &&
107
+ <>
108
+ {
109
+ urls.map((url, i) => (
110
+ <div key={i} class="flex align-items-center gap-1">
111
+ <img src={url} width="100%" />
112
+ </div>
113
+ ))
114
+ }
115
+ <div>
116
+ Your file has been added successfully.&nbsp;
117
+ <Link to="#" class="text-primary" onClick={tryAgain}>Use another file?</Link>
118
+ </div>
119
+ </>
120
+ }
121
+ </DropHandler>
122
+ {error && <div class="form-error mt-0-5">{error.detail}</div>}
123
+ </div>
124
+ )
125
+ }
@@ -0,0 +1,21 @@
1
+ export function FormError({ state, fields, className }) {
2
+ /**
3
+ * this is a catch all error component that should be placed next to the submit button
4
+ * @param {object} state
5
+ * @param {array} <fields> - display all errors except these field titles, e.g. ['name', 'address']
6
+ */
7
+ for (let item of state.errors || []) {
8
+ if (!item.title || item.title.match(/^(error|invalid)$/i) || (fields && !fields.includes(item.title))) {
9
+ var error = item
10
+ }
11
+ }
12
+ return (
13
+ <>
14
+ {error ? (
15
+ <div class={`text-danger mt-1 text-sm ${className||''}`}>
16
+ {error.detail}
17
+ </div>
18
+ ) : null}
19
+ </>
20
+ )
21
+ }
@@ -0,0 +1,77 @@
1
+ import { css } from 'twin.macro'
2
+ import { hsvaToHex, hexToHsva, validHex } from '@uiw/color-convert'
3
+ import Saturation from '@uiw/react-color-saturation'
4
+ import Hue from '@uiw/react-color-hue'
5
+ import { Dropdown } from '../element/dropdown.jsx'
6
+ import { throttle } from '../../../util.js'
7
+
8
+ export function InputColor({ className, defaultColor='#333', iconEl, id, onChange, value, ...props }) {
9
+ const [lastChanged, setLastChanged] = useState(() => `ic-${Date.now()}`)
10
+ const isInvalid = className?.includes('is-invalid') ? 'is-invalid' : ''
11
+
12
+ function onInputChange(e) {
13
+ setLastChanged(`ic-${Date.now()}`)
14
+ if (onChange) onChange(e)
15
+ }
16
+
17
+ return (
18
+ <Dropdown
19
+ css={style}
20
+ menuToggles={false}
21
+ menuChildren={
22
+ <ColorPicker key={lastChanged} defaultColor={defaultColor} id={id} value={value} onChange={onChange} />
23
+ }
24
+ >
25
+ <div className="grid grid-cols-1">
26
+ {iconEl}
27
+ <input
28
+ {...props}
29
+ className={className + ' ' + isInvalid}
30
+ id={id}
31
+ value={value}
32
+ onChange={onInputChange}
33
+ onBlur={() => !validHex(value) && onInputChange({ target: { id: id, value: '' }})}
34
+ autoComplete="off"
35
+ />
36
+ </div>
37
+ </Dropdown>
38
+ )
39
+ }
40
+
41
+ function ColorPicker({ id, onChange, value, defaultColor }) {
42
+ const [hsva, setHsva] = useState(() => hexToHsva(validHex(value) ? value : defaultColor))
43
+ const [debounce] = useState(() => throttle(callOnChange, 50))
44
+
45
+ function callOnChange(newHsva) {
46
+ onChange({ target: { id: id, value: hsvaToHex(newHsva) }})
47
+ }
48
+
49
+ return (
50
+ <>
51
+ <Saturation
52
+ hsva={hsva}
53
+ onChange={(newHsva) => {
54
+ setHsva(newHsva)
55
+ if (onChange) debounce(newHsva)
56
+ }}
57
+ />
58
+ <Hue
59
+ hue={hsva.h}
60
+ onChange={(newHue) => {
61
+ setHsva({ ...hsva, ...newHue })
62
+ if (onChange) debounce({ ...hsva, ...newHue })
63
+ }}
64
+ />
65
+ </>
66
+ )
67
+ }
68
+
69
+ const style = () => css`
70
+ text-indent: 0 !important; // since is-color is on dropdown
71
+ .w-color-interactive {
72
+ width: 100% !important;
73
+ height: 150px !important;
74
+ }
75
+ `
76
+
77
+
@@ -0,0 +1,133 @@
1
+ /*eslint-disable*/
2
+ import { NumericFormat } from 'react-number-format'
3
+ import { getCurrencyPrefixWidth } from '../../../util.js'
4
+
5
+ /**
6
+ * @param {string} id - field name or path on state
7
+ * @param {object} config - e.g. { currencies: { nzd: { symbol: '$', digits: 2 } } }
8
+ * @param {string} [currency] - currency iso
9
+ * @param {function} [onChange] - e.g. (event) => onInputChange(event)
10
+ * @param {string} [placeholder] - e.g. 'Amount'
11
+ * @param {cents} [value] - e.g. 123 (input is always controlled if state is passed in)
12
+ */
13
+ export function InputCurrency({ id, config, className, currency='nzd', onChange, placeholder, value }) {
14
+ const ref = useRef()
15
+ const [dontFix, setDontFix] = useState()
16
+ const [settings, setSettings] = useState(() => getCurrencySettings(currency))
17
+ const [dollars, setDollars] = useState(() => toDollars(value, true, settings))
18
+ const [prefixWidth, setPrefixWidth] = useState()
19
+ ref.current = { settings, dontFix }
20
+
21
+ if (!config) throw new Error('InputCurrency: `config` is required')
22
+
23
+ useEffect(() => {
24
+ if (settings.currency !== currency) {
25
+ const settings = getCurrencySettings(currency)
26
+ setSettings(settings)
27
+ setDollars(toDollars(value, true, settings)) // required latest _settings
28
+ }
29
+ }, [currency])
30
+
31
+ useEffect(() => {
32
+ if (ref.current.dontFix) {
33
+ setDollars(toDollars(value))
34
+ setDontFix(false)
35
+ } else {
36
+ setDollars(toDollars(value, true))
37
+ }
38
+ }, [value])
39
+
40
+
41
+ useEffect(() => {
42
+ // Get the prefix content width
43
+ setPrefixWidth(settings.prefix == '$' ? getCurrencyPrefixWidth(settings.prefix, 1) : 0)
44
+ }, [settings.prefix])
45
+
46
+ function toCents(num) {
47
+ if (!num && num !== 0) return null
48
+ const value = Math.round(num * Math.pow(10, ref.current.settings.maxDecimals)) // e.g. 1.23 => 123
49
+ // console.log('toCents', num, value)
50
+ return value
51
+ }
52
+
53
+ function toDollars(num, toFixed, settings) {
54
+ if (!num && num !== 0) return null
55
+ const value = num / Math.pow(10, (settings||ref.current.settings).maxDecimals) // e.g. 1.23 => 123
56
+ // console.log('toDollars', num, value)
57
+ return toFixed ? value.toFixed((settings||ref.current.settings).maxDecimals) : value
58
+ }
59
+
60
+ function getCurrencySettings(currency) {
61
+ /**
62
+ * parse CLDR currency format, e.g. '¤#,##0.00'
63
+ * @param {string} currency - currency iso
64
+ * @returns {object}
65
+ * {
66
+ * currency: 'nzd',
67
+ * decimalSeparator: '.',
68
+ * thousandSeparator: ',',
69
+ * minDecimals: 2,
70
+ * maxDecimals: 2,
71
+ * prefix: '$',
72
+ * suffix: '',
73
+ * }
74
+ */
75
+ const output = { currency }
76
+ const { symbol, digits } = config.currencies[currency]
77
+ let format = config.countries['nz'].numberFormats.currency
78
+
79
+ // Check for currency symbol (¤) and determine its position
80
+ if (format.indexOf('¤') !== -1) {
81
+ const position = format.indexOf('¤') === 0 ? 'prefix' : 'suffix'
82
+ output[position] = symbol
83
+ format = format.replace('¤', '')
84
+ }
85
+
86
+ // Find and set the thousands separator
87
+ const thousandMatch = format.match(/[^0-9#]/)
88
+ if (thousandMatch) output.thousandSeparator = thousandMatch[0]
89
+
90
+ // Find and set the decimal separator and fraction digits
91
+ const decimalMatch = format.match(/0[^0-9]/)
92
+ if (decimalMatch) {
93
+ output.decimalSeparator = decimalMatch[0].slice(1)
94
+ if (typeof digits !== 'undefined') {
95
+ output.minDecimals = digits
96
+ output.maxDecimals = digits
97
+ } else {
98
+ const fractionDigits = format.split(output.decimalSeparator)[1]
99
+ if (fractionDigits) {
100
+ output.minDecimals = fractionDigits.length
101
+ output.maxDecimals = fractionDigits.length
102
+ }
103
+ }
104
+ }
105
+ return output
106
+ }
107
+
108
+ return (
109
+ <div className="relative">
110
+ <NumericFormat
111
+ id={id}
112
+ className={className}
113
+ decimalSeparator={settings.decimalSeparator}
114
+ thousandSeparator={settings.thousandSeparator}
115
+ decimalScale={settings.maxDecimals}
116
+ onValueChange={!onChange ? undefined : ({ floatValue }, e) => {
117
+ // console.log('onValueChange', floatValue, e)
118
+ if (e.source === 'event') setDontFix(true)
119
+ onChange({ target: { id: id, value: toCents(floatValue) }})
120
+ }}
121
+ onBlur={() => { setDollars(toDollars(value, true))}}
122
+ placeholder={placeholder || '0.00'}
123
+ value={dollars}
124
+ style={{ textIndent: `${prefixWidth}px` }}
125
+ />
126
+ <span
127
+ class={`absolute top-[1px] bottom-0 left-3 inline-flex items-center select-none text-gray-500 text-sm sm:text-sm/6 ${dollars !== null && settings.prefix == '$' ? 'text-dark' : ''}`}
128
+ >
129
+ {settings.prefix || settings.suffix}
130
+ </span>
131
+ </div>
132
+ )
133
+ }