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,223 @@
1
+ // todo: finish tailwind conversion
2
+ import { css, theme } from 'twin.macro'
3
+ import { DayPicker } from 'react-day-picker'
4
+ import { format, isValid, parse } from 'date-fns'
5
+ import { getCurrencyPrefixWidth } from '../../../util.js'
6
+ import { Dropdown } from '../element/dropdown.jsx'
7
+ import 'react-day-picker/dist/style.css'
8
+
9
+ export function InputDate({ className, prefix, id, onChange, mode='single', value, ...props }) {
10
+ /**
11
+ * @param {string} mode - 'single'|'range'|'multiple' - an array is returned for non-single modes
12
+ */
13
+ const localePattern = 'd MMM yyyy'
14
+ const isInvalid = className?.includes('is-invalid') ? 'is-invalid' : ''
15
+ const [prefixWidth, setPrefixWidth] = useState()
16
+ const ref = useRef(null)
17
+
18
+ const dates = useMemo(() => {
19
+ // Convert the value to an array of valid* dates
20
+ const _dates = Array.isArray(value) ? value : [value]
21
+ return _dates.map(date => isValid(date) ? new Date(date) : undefined)
22
+ }, [value])
23
+
24
+ // Hold the month in state to control the calendar when the input changes
25
+ const [month, setMonth] = useState(dates[0])
26
+
27
+ // Hold the input value in state
28
+ const [inputValue, setInputValue] = useState(() => getInputValue(dates))
29
+
30
+ useEffect(() => {
31
+ // Get the prefix content width
32
+ setPrefixWidth(getCurrencyPrefixWidth(prefix, 4))
33
+ }, [prefix])
34
+
35
+ function handleDayPickerSelect(newDate) {
36
+ if (mode == 'single') {
37
+ ref.current.setIsActive(false) // close the dropdown
38
+ callOnChange(newDate?.getTime() || null)
39
+ setInputValue(getInputValue([newDate]))
40
+
41
+ } else if (mode == 'range') {
42
+ const {from, to} = newDate || {} // may not exist
43
+ callOnChange(from ? [from?.getTime() || null, to?.getTime() || null] : null)
44
+ setInputValue(getInputValue(from ? [from, to] : []))
45
+
46
+ } else {
47
+ callOnChange(newDate.filter(o => o).map(d => d.getTime()))
48
+ setInputValue(getInputValue(newDate.filter(o => o)))
49
+ }
50
+ }
51
+
52
+ function handleInputChange(e) {
53
+ setInputValue(e.target.value) // keep the input value in sync
54
+
55
+ let split = e.target.value.split(/-|,/).map(o => {
56
+ const date = parse(o.trim(), localePattern, new Date())
57
+ return isValid(date) ? date : null
58
+ })
59
+
60
+ // For single/range we need limit the array
61
+ if (mode == 'range') split.length = 2
62
+ else if (mode == 'multiple') split = split.filter(o => o) // remove invalid dates
63
+
64
+ // Swap dates if needed
65
+ if (mode == 'range' && split[0] > split[1]) split = [split[0], split[0]]
66
+
67
+ // Set month
68
+ for (let i=split.length; i--;) {
69
+ if (split[i]) setMonth(split[i])
70
+ break
71
+ }
72
+
73
+ // Set dates
74
+ callOnChange(mode == 'single' ? split[0] : split)
75
+ }
76
+
77
+ function getInputValue(dates) {
78
+ return dates.map(o => o ? format(o, localePattern) : '').join(mode == 'range' ? ' - ' : ', ')
79
+ }
80
+
81
+ function callOnChange(value) {
82
+ if (onChange) onChange({ target: { id: id, value: value }}) // timestamp|[timestamp]
83
+ }
84
+
85
+ return (
86
+ <Dropdown
87
+ ref={ref}
88
+ css={style}
89
+ menuToggles={false}
90
+ animate={false}
91
+ // menuIsOpen={true}
92
+ menuChildren={
93
+ <DayPicker
94
+ mode={mode}
95
+ month={month}
96
+ onMonthChange={setMonth}
97
+ numberOfMonths={mode == 'range' ? 2 : 1}
98
+ selected={mode === 'single' ? dates[0] : mode == 'range' ? { from: dates[0], to: dates[1] } : dates}
99
+ onSelect={handleDayPickerSelect}
100
+ />
101
+ }
102
+ >
103
+ <div>
104
+ {prefix && <span class={`input-prefix ${inputValue ? 'has-value' : ''}`}>{prefix}</span>}
105
+ <input
106
+ {...props}
107
+ key={'k'+prefixWidth}
108
+ id={id}
109
+ autoComplete="off"
110
+ className={
111
+ className + ' ' + isInvalid
112
+ }
113
+ value={inputValue}
114
+ onChange={handleInputChange}
115
+ onBlur={() => setInputValue(getInputValue(dates))}
116
+ style={{ textIndent: prefixWidth + 'px' }}
117
+ />
118
+ </div>
119
+ </Dropdown>
120
+ )
121
+ }
122
+
123
+ const style = () => css`
124
+ .rdp {
125
+ --rdp-cell-size: 34px;
126
+ --rdp-caption-font-size: 12px;
127
+ --rdp-accent-color: ${theme('colors.primary')};
128
+ font-size: 13px;
129
+ margin: 0 12px 11px;
130
+ svg {
131
+ width: 13px;
132
+ height: 13px;
133
+ }
134
+ .rdp-caption_label {
135
+ height: var(--rdp-cell-size);
136
+ }
137
+ .rdp-head_cell {
138
+ text-align: center !important;
139
+ }
140
+ tr {
141
+ display: flex;
142
+ justify-content: space-around;
143
+ align-items: center;
144
+ th,
145
+ td {
146
+ display: flex;
147
+ align-items: center;
148
+ margin-left: -1px;
149
+ margin-top: -1px;
150
+ .rdp-day {
151
+ border: 0 !important;
152
+ position: relative;
153
+ border-radius: 0 !important;
154
+ color: inherit;
155
+ background-color: transparent !important;
156
+ &:before {
157
+ content: '';
158
+ position: absolute;
159
+ display: block;
160
+ left: 0px;
161
+ top: 0px;
162
+ bottom: 0px;
163
+ right: 0px;
164
+ z-index: -1;
165
+ }
166
+ }
167
+ .rdp-day:focus,
168
+ .rdp-day:hover,
169
+ .rdp-day:active {
170
+ &:not([disabled]):not(.rdp-day_selected) {
171
+ &:before {
172
+ left: 1px;
173
+ top: 1px;
174
+ bottom: 1px;
175
+ right: 1px;
176
+ border-radius: 50%;
177
+ background-color: #e7edff;
178
+ }
179
+ &:active {
180
+ color: white;
181
+ &:before {
182
+ background-color: ${theme('colors.primary')};
183
+ }
184
+ }
185
+ }
186
+ }
187
+ .rdp-day_selected {
188
+ color: white;
189
+ :before {
190
+ border-radius: 50%;
191
+ background-color: ${theme('colors.primary')};
192
+ }
193
+ }
194
+ .rdp-day_range_middle {
195
+ color: ${theme('colors.dark')};
196
+ :before {
197
+ border-radius: 0;
198
+ border: 1px solid rgb(151 133 185);
199
+ background-color: ${theme('colors.primary-light')};
200
+ }
201
+ }
202
+ .rdp-day_range_start,
203
+ .rdp-day_range_end {
204
+ position: relative;
205
+ z-index: 1;
206
+ &.rdp-day_range_start:before {
207
+ border-top-right-radius: 0px;
208
+ border-bottom-right-radius: 0px;
209
+ }
210
+ &.rdp-day_range_end:before {
211
+ border-top-left-radius: 0px;
212
+ border-bottom-left-radius: 0px;
213
+ }
214
+ &.rdp-day_range_start.rdp-day_range_end:before {
215
+ border-radius: 50%;
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+ `
222
+
223
+
@@ -0,0 +1,131 @@
1
+ /* eslint-disable brace-style */
2
+ import { css } from '@emotion/react'
3
+ import * as util from '../../../util.js'
4
+ import { InputCurrency } from './input-currency.jsx'
5
+ import { InputColor } from './input-color.jsx'
6
+ // import { InputDate } from './input-date.jsx'
7
+ import {
8
+ EnvelopeIcon,
9
+ // CalendarIcon,
10
+ FunnelIcon,
11
+ MagnifyingGlassIcon,
12
+ EyeIcon,
13
+ EyeSlashIcon,
14
+ } from '@heroicons/react/20/solid'
15
+
16
+ /**
17
+ * Input
18
+ * @param {string} name - field name or path on state (used to match errors), e.g. 'date', 'company.email'
19
+ * @param {object} state - State object to get the value, and check errors against
20
+ * @param {string} [id] - not required, name used if not provided
21
+ * @param {('password'|'email'|'text'|'date'|'filter'|'search'|'color'|'textarea'|'currency')} [type='text']
22
+ */
23
+ export function Input({ name='', state, id, type='text', ...props }) {
24
+ let iconDir = 'right'
25
+ let InputEl = 'input'
26
+ const [inputType, setInputType] = useState(() => {
27
+ return type == 'password' ? 'password' : (type == 'textarea' ? type : 'text')
28
+ })
29
+
30
+ if (!name) throw new Error('Input component requires a `name` prop')
31
+
32
+ // Input is always controlled if state is passed in
33
+ if (props.value) {
34
+ var value = props.value
35
+ } else if (typeof state == 'object') {
36
+ value = util.deepFind(state, name)
37
+ if (typeof value == 'undefined') value = ''
38
+ }
39
+
40
+ // Find any errors that match this input path
41
+ for (let item of (state?.errors || [])) {
42
+ if (util.isRegex(name) && (item.title||'').match(name)) var error = item
43
+ else if (item.title == name) error = item
44
+ }
45
+
46
+ // Special input types
47
+ if (type == 'password') {
48
+ var onClick = () => setInputType(o => o == 'password' ? 'text' : 'password')
49
+ var IconSvg = inputType == 'password' ? <EyeSlashIcon /> : <EyeIcon />
50
+ } else if (type == 'email') {
51
+ IconSvg = <EnvelopeIcon />
52
+ // } else if (type == 'date') {
53
+ // IconSvg = <CalendarIcon />
54
+ // InputEl = InputDate
55
+ } else if (type == 'filter') {
56
+ IconSvg = <FunnelIcon />
57
+ } else if (type == 'search') {
58
+ IconSvg = <MagnifyingGlassIcon />
59
+ } else if (type == 'color') {
60
+ iconDir = 'left'
61
+ IconSvg = <ColorIcon hex={value}/>
62
+ InputEl = InputColor
63
+ } else if (type == 'textarea') {
64
+ InputEl = 'textarea'
65
+ } else if (type == 'currency') {
66
+ if (!props.config) throw new Error('Input: `config` is required when type=currency')
67
+ InputEl = InputCurrency
68
+ }
69
+
70
+ // Create base props object
71
+ const inputProps = {
72
+ ...props,
73
+ // autoComplete: props.autoComplete || 'off',
74
+ id: id || name,
75
+ type: inputType,
76
+ value: value,
77
+ className:
78
+ 'col-start-1 row-start-1 block w-full rounded-md bg-white py-2 text-sm outline outline-1 -outline-offset-1 ' +
79
+ 'placeholder:text-input-placeholder focus:outline focus:outline-2 focus:-outline-offset-2 sm:text-sm/6 ' +
80
+ (iconDir == 'right' && IconSvg ? 'sm:pr-9 pl-3 pr-10 ' : IconSvg ? 'sm:pl-9 pl-10 pr-3 ' : 'px-3 ') +
81
+ (error ? 'text-red-900 outline-danger focus:outline-danger ' : 'text-input outline-input-border focus:outline-primary ') +
82
+ (iconDir == 'right' ? 'justify-self-start ' : 'justify-self-end '),
83
+ }
84
+
85
+ // Only add iconEl prop for custom components
86
+ const showIconElHere = !['color', 'date'].includes(type)
87
+ const iconEl = <IconEl iconDir={iconDir} IconSvg={IconSvg} onClick={onClick} type={type} />
88
+ if (!showIconElHere) {
89
+ inputProps.iconEl = iconEl
90
+ }
91
+
92
+ return (
93
+ // https://tailwindui.com/components/application-ui/forms/input-groups#component-474bd025b849b44eb3c46df09a496b7a
94
+ <div css={style} className={`mt-input-before mb-input-after grid grid-cols-1 ${props?.className || ''}`}>
95
+ { showIconElHere && iconEl }
96
+ <InputEl {...inputProps} />
97
+ {error && <div class="mt-1.5 text-xs text-danger">{error.detail}</div>}
98
+ </div>
99
+ )
100
+ }
101
+
102
+ function IconEl({ iconDir, IconSvg, onClick, type }) {
103
+ const iconSize = type == 'color' ? 'size-[18px]' : 'size-4'
104
+ return (
105
+ !!IconSvg &&
106
+ <div
107
+ className={`col-start-1 row-start-1 ${iconSize} self-center text-gray-400 select-none relative z-[1] ` +
108
+ `pointer-events-${type == 'password' ? 'auto' : 'none'} ` +
109
+ (iconDir == 'right' ? 'justify-self-end mr-3' : 'justify-self-start ml-3')
110
+ }
111
+ onClick={onClick}
112
+ >{IconSvg}</div>
113
+ )
114
+ }
115
+
116
+ function ColorIcon({ hex }) {
117
+ return (
118
+ <span class="block size-full rounded-md" style={{ backgroundColor: hex ? hex : '#f1f1f1' }}></span>
119
+ )
120
+ }
121
+
122
+ const style = () => css`
123
+ input {
124
+ -moz-appearance: textfield;
125
+ }
126
+ input::-webkit-outer-spin-button,
127
+ input::-webkit-inner-spin-button {
128
+ -webkit-appearance: none;
129
+ margin: 0;
130
+ }
131
+ `
@@ -0,0 +1,212 @@
1
+ // todo: finish tailwind conversion
2
+ import * as util from '../../../util.js'
3
+
4
+ export function Location({ clear, id, name, onInput, onSelect, placeholder, placeTypes, value, googleMapsApiKey }) {
5
+ /**
6
+ * Get location or area of place (requires both 'maps javascript' and 'places' APIs)
7
+ *
8
+ * @param {boolean} clear - clear input after select
9
+ * @param {function(place)} onInput - called when the input value changes, with an
10
+ * empty place, e.g. {full: '...', fullModified: true}
11
+ * @param {function(place)} onSelect - called when a place is selected
12
+ * @param {object} value - {full, line1, ..etc}
13
+ *
14
+ * Handy box tester (see also util.mongoAddKmsToBox())
15
+ * https://www.keene.edu/campus/maps/tool/
16
+ *
17
+ * Returned Google places viewport (area), i.e. `place.geometry.viewport`
18
+ * {
19
+ * Qa: {g: 174.4438160493033, h: 174.9684260722261} == [btmLng, topLng]
20
+ * zb: {g: -37.05901990116617, h: -36.66060184426172} == [btmLat, topLat]
21
+ * }
22
+ */
23
+ const inputRef = useRef(null)
24
+ const full = (value || {}).full || ''
25
+ const [inputValue, setInputValue] = useState(full)
26
+
27
+ useEffect(() => {
28
+ if (!onSelect) console.error('Please pass `onSelect` to location.jsx')
29
+ let autoComplete
30
+ loadGoogleMaps(googleMapsApiKey).then(() => {
31
+ if (inputRef.current) {
32
+ autoComplete = new window.google.maps.places.Autocomplete(inputRef.current, {
33
+ types: placeTypes ? placeTypes : ['address'],
34
+ componentRestrictions: { country: ['nz'] },
35
+ })
36
+ autoComplete.setFields(['address_components', 'formatted_address', 'geometry'])
37
+ autoComplete.addListener('place_changed', onPlaceSelect)
38
+ inputRef.current.addEventListener('keydown', onKeyDown)
39
+ }
40
+ })
41
+ return () => {
42
+ // It seems like autoComplete cleans up both listeners, handy links if needing to remove sooner..
43
+ // Cleanup listners: https://stackoverflow.com/a/22862011/1900648
44
+ // Cleanup .pac-container: https://stackoverflow.com/a/21419890/1900648
45
+ for (let elem of document.getElementsByClassName('pac-container')) elem.remove()
46
+ }
47
+ }, [])
48
+
49
+ useEffect(() => {
50
+ if (full !== inputValue) setInputValue(full)
51
+ }, [full])
52
+
53
+ function formatAddressObject(place) {
54
+ console.log(place)
55
+ var addressMap = {
56
+ city: ['locality'],
57
+ country: ['country'],
58
+ number: ['street_number'],
59
+ postcode: ['postal_code'],
60
+ region: [
61
+ 'administrative_area_level_1',
62
+ 'administrative_area_level_2',
63
+ 'administrative_area_level_3',
64
+ 'administrative_area_level_4',
65
+ 'administrative_area_level_5',
66
+ ],
67
+ street: ['street_address', 'route'],
68
+ suburb: [
69
+ 'sublocality',
70
+ 'sublocality_level_1',
71
+ 'sublocality_level_2',
72
+ 'sublocality_level_3',
73
+ 'sublocality_level_4',
74
+ ],
75
+ unit: ['subpremise'],
76
+ }
77
+ var address = {
78
+ city: '',
79
+ country: '',
80
+ number: '',
81
+ postcode: '',
82
+ region: '',
83
+ street: '',
84
+ suburb: '',
85
+ unit: '',
86
+ }
87
+ place.address_components.forEach((component) => {
88
+ for (var key in addressMap) {
89
+ if (addressMap[key].indexOf(component.types[0]) !== -1) {
90
+ address[key] = component.long_name
91
+ }
92
+ }
93
+ })
94
+ if (!address.city) {
95
+ address.city = address.suburb
96
+ address.suburb = ''
97
+ }
98
+ return address
99
+ }
100
+
101
+ function onPlaceSelect() {
102
+ let place = this.getPlace()
103
+ if (!place.geometry) return
104
+ if (clear) setInputValue('')
105
+ else setInputValue(place.formatted_address)
106
+ let addressObject = formatAddressObject(place)
107
+ onSelect({
108
+ city: addressObject.city,
109
+ country: addressObject.country,
110
+ line1: [[addressObject.unit, addressObject.number].filter(o=>o).join('/'), addressObject.street].join(' '),
111
+ line2: [addressObject.suburb, addressObject.postcode].filter(o=>o).join(', '),
112
+ full: place.formatted_address,
113
+ number: addressObject.number,
114
+ postcode: addressObject.postcode,
115
+ suburb: addressObject.suburb,
116
+ location: {
117
+ coordinates: [place.geometry.location.lng(), place.geometry.location.lat()],
118
+ type: 'Point',
119
+ },
120
+ unit: addressObject.unit,
121
+ area: !util.deepFind(place, 'geometry.viewport') ? undefined : {
122
+ bottomLeft: [
123
+ place.geometry.viewport.getSouthWest().lng(),
124
+ place.geometry.viewport.getSouthWest().lat(),
125
+ ],
126
+ topRight: [
127
+ place.geometry.viewport.getNorthEast().lng(),
128
+ place.geometry.viewport.getNorthEast().lat(),
129
+ ],
130
+ },
131
+ })
132
+ }
133
+
134
+ function onChange(event) {
135
+ // On input change
136
+ setInputValue(event.target.value)
137
+ if (onInput) onInput({
138
+ full: event.target.value,
139
+ fullModified: true,
140
+ })
141
+ }
142
+
143
+ function onFocus(event) {
144
+ // Required to disable the chrome autocomplete, https://stackoverflow.com/a/57131179/4553162
145
+ if (event.target.autocomplete) {
146
+ event.target.autocomplete = 'off'
147
+ }
148
+ }
149
+
150
+ function onKeyDown(event) {
151
+ // Stop form submission if there is a google autocomplete dropdown opened
152
+ let prevented
153
+ if (event.key === 'Enter') {
154
+ for (let el of document.getElementsByClassName('pac-container')) {
155
+ if (el.offsetParent !== null && !prevented) { // google autocomplete opened somewhere
156
+ event.preventDefault()
157
+ prevented = true
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ return (
164
+ <input
165
+ id={id||name}
166
+ name={name||id}
167
+ onChange={onChange}
168
+ onFocus={onFocus}
169
+ placeholder={placeholder}
170
+ ref={inputRef}
171
+ type="text"
172
+ value={inputValue}
173
+ />
174
+ )
175
+ }
176
+
177
+ function loadGoogleMaps(googleMapsApiKey) {
178
+ // Requires both 'maps javascript api' and 'places api' within Goolge Cloud Platform
179
+ if (!window.initMap) {
180
+ window.initMap = () => {/*noop to prevent warning*/}
181
+ }
182
+
183
+ return new Promise((res) => {
184
+ let scriptId = 'googleMapsUrl'
185
+ let script = document.getElementById(scriptId)
186
+ // script not yet inserted
187
+ if (script === null) {
188
+ script = document.createElement('script')
189
+ script.type = 'text/javascript'
190
+ script.id = scriptId
191
+ script.src = `https://maps.googleapis.com/maps/api/js?key=${googleMapsApiKey}`+
192
+ '&libraries=places&callback=initMap'
193
+ script.onload = () => res()
194
+ document.getElementsByTagName('head')[0].appendChild(script)
195
+ // script has already been inserted
196
+ } else {
197
+ // script has already loaded
198
+ if (window.google) {
199
+ res()
200
+ // script hasn't been loaded yet
201
+ } else {
202
+ let cachedCallback = script.onload
203
+ script.onload = () => {
204
+ cachedCallback()
205
+ res()
206
+ }
207
+ }
208
+ }
209
+ })
210
+ }
211
+
212
+ // Styles are in custom.css