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.
- package/.editorconfig +9 -0
- package/.eslintrc.json +86 -0
- package/_example/.env-example +16 -0
- package/_example/client/config.ts +5 -0
- package/_example/client/css/index.css +35 -0
- package/_example/client/fonts/Roboto-Bold.ttf +0 -0
- package/_example/client/fonts/Roboto-BoldItalic.ttf +0 -0
- package/_example/client/fonts/Roboto-Italic.ttf +0 -0
- package/_example/client/fonts/Roboto-Medium.ttf +0 -0
- package/_example/client/fonts/Roboto-MediumItalic.ttf +0 -0
- package/_example/client/fonts/Roboto-Regular.ttf +0 -0
- package/_example/client/fonts/inter-v13-latin-300.woff2 +0 -0
- package/_example/client/fonts/inter-v13-latin-500.woff2 +0 -0
- package/_example/client/fonts/inter-v13-latin-600.woff2 +0 -0
- package/_example/client/fonts/inter-v13-latin-700.woff2 +0 -0
- package/_example/client/fonts/inter-v13-latin-800.woff2 +0 -0
- package/_example/client/fonts/inter-v13-latin-900.woff2 +0 -0
- package/_example/client/fonts/inter-v13-latin-regular.woff2 +0 -0
- package/_example/client/imgs/android-chrome-512x512.png +0 -0
- package/_example/client/imgs/favicon.png +0 -0
- package/_example/client/imgs/icons/calendar.svg +3 -0
- package/_example/client/imgs/icons/email.svg +6 -0
- package/_example/client/imgs/icons/eye-open.svg +4 -0
- package/_example/client/imgs/icons/eye.svg +5 -0
- package/_example/client/imgs/icons/filter.svg +7 -0
- package/_example/client/imgs/icons/left-circle.svg +3 -0
- package/_example/client/imgs/icons/left.svg +3 -0
- package/_example/client/imgs/icons/line-options.svg +5 -0
- package/_example/client/imgs/icons/line.svg +3 -0
- package/_example/client/imgs/icons/person.svg +7 -0
- package/_example/client/imgs/icons/plus-circle.svg +5 -0
- package/_example/client/imgs/icons/plus.svg +5 -0
- package/_example/client/imgs/icons/right-circle.svg +3 -0
- package/_example/client/imgs/icons/right.svg +3 -0
- package/_example/client/imgs/icons/search.svg +3 -0
- package/_example/client/imgs/icons/shield.svg +6 -0
- package/_example/client/imgs/icons/tick-circle-solid.svg +8 -0
- package/_example/client/imgs/icons/tick-circle.svg +6 -0
- package/_example/client/imgs/icons/tick.svg +5 -0
- package/_example/client/imgs/icons/up2-small.svg +4 -0
- package/_example/client/imgs/icons/up2.svg +4 -0
- package/_example/client/imgs/icons/updown.svg +6 -0
- package/_example/client/imgs/icons/v-big-dark.svg +3 -0
- package/_example/client/imgs/icons/v-dark.svg +3 -0
- package/_example/client/imgs/icons/v.svg +3 -0
- package/_example/client/imgs/icons/v2-active.svg +6 -0
- package/_example/client/imgs/icons/x1.svg +4 -0
- package/_example/client/imgs/logo/logo-white.svg +20 -0
- package/_example/client/imgs/logo/logo.svg +20 -0
- package/_example/client/imgs/no-image.jpg +0 -0
- package/_example/client/imgs/user.jpg +0 -0
- package/_example/client/index.html +12 -0
- package/_example/client/index.ts +47 -0
- package/_example/components/auth.api.js +1 -0
- package/_example/components/index.tsx +225 -0
- package/_example/components/partials/layouts.tsx +5 -0
- package/_example/components/settings.api.js +1 -0
- package/_example/server/config.js +120 -0
- package/_example/server/email/welcome.html +27 -0
- package/_example/server/index.js +32 -0
- package/_example/tailwind.config.js +84 -0
- package/_example/tsconfig.json +32 -0
- package/_example/types.d.ts +7 -0
- package/_example/webpack.config.js +4 -0
- package/client/app.js +300 -0
- package/client/css/components.css +84 -0
- package/client/css/fonts.css +67 -0
- package/client/imgs/icons/calendar.svg +3 -0
- package/client/imgs/icons/email.svg +6 -0
- package/client/imgs/icons/eye-open.svg +4 -0
- package/client/imgs/icons/eye.svg +5 -0
- package/client/imgs/icons/filter.svg +7 -0
- package/client/imgs/icons/left-circle.svg +3 -0
- package/client/imgs/icons/left.svg +3 -0
- package/client/imgs/icons/line-options.svg +5 -0
- package/client/imgs/icons/line.svg +3 -0
- package/client/imgs/icons/person.svg +7 -0
- package/client/imgs/icons/plus-circle.svg +5 -0
- package/client/imgs/icons/plus.svg +5 -0
- package/client/imgs/icons/right-circle.svg +3 -0
- package/client/imgs/icons/right.svg +3 -0
- package/client/imgs/icons/search.svg +3 -0
- package/client/imgs/icons/shield.svg +6 -0
- package/client/imgs/icons/tick-circle-solid.svg +8 -0
- package/client/imgs/icons/tick-circle.svg +6 -0
- package/client/imgs/icons/tick.svg +5 -0
- package/client/imgs/icons/up2-small.svg +4 -0
- package/client/imgs/icons/up2.svg +4 -0
- package/client/imgs/icons/updown.svg +6 -0
- package/client/imgs/icons/v-big-dark.svg +3 -0
- package/client/imgs/icons/v-dark.svg +3 -0
- package/client/imgs/icons/v.svg +3 -0
- package/client/imgs/icons/v2-active.svg +6 -0
- package/client/imgs/icons/x1.svg +4 -0
- package/client.js +42 -0
- package/components/auth/auth.api.js +419 -0
- package/components/auth/reset.jsx +88 -0
- package/components/auth/signin.jsx +74 -0
- package/components/auth/signup.jsx +62 -0
- package/components/billing/stripe.api.js +267 -0
- package/components/partials/element/accordion.jsx +82 -0
- package/components/partials/element/avatar.jsx +28 -0
- package/components/partials/element/button.jsx +66 -0
- package/components/partials/element/dropdown.jsx +185 -0
- package/components/partials/element/initials.jsx +56 -0
- package/components/partials/element/message.jsx +124 -0
- package/components/partials/element/modal.jsx +229 -0
- package/components/partials/element/sidebar.jsx +166 -0
- package/components/partials/element/tooltip.jsx +146 -0
- package/components/partials/element/topbar.jsx +25 -0
- package/components/partials/form/checkbox.jsx +74 -0
- package/components/partials/form/drop-handler.jsx +62 -0
- package/components/partials/form/drop.jsx +125 -0
- package/components/partials/form/form-error.jsx +21 -0
- package/components/partials/form/input-color.jsx +77 -0
- package/components/partials/form/input-currency.jsx +133 -0
- package/components/partials/form/input-date.jsx +223 -0
- package/components/partials/form/input.jsx +131 -0
- package/components/partials/form/location.jsx +212 -0
- package/components/partials/form/select.jsx +369 -0
- package/components/partials/form/toggle.jsx +46 -0
- package/components/partials/is-first-render.js +15 -0
- package/components/partials/layout/layout1.jsx +32 -0
- package/components/partials/layout/layout2.jsx +47 -0
- package/components/partials/not-found.jsx +7 -0
- package/components/partials/styleguide.jsx +252 -0
- package/components/settings/settings-account.jsx +143 -0
- package/components/settings/settings-business.jsx +121 -0
- package/components/settings/settings-team--member.jsx +108 -0
- package/components/settings/settings-team.jsx +76 -0
- package/components/settings/settings.api.js +54 -0
- package/package.json +175 -0
- package/readme.md +43 -0
- package/server/email/index.js +192 -0
- package/server/email/partials/email.css +153 -0
- package/server/email/partials/layout1.swig +92 -0
- package/server/email/partials/line.swig +8 -0
- package/server/email/partials/vert-10.swig +8 -0
- package/server/email/partials/vert-15.swig +8 -0
- package/server/email/partials/vert-20.swig +8 -0
- package/server/email/partials/vert-25.swig +8 -0
- package/server/email/partials/vert-30.swig +8 -0
- package/server/email/partials/vert-35.swig +8 -0
- package/server/email/partials/vert-50.swig +8 -0
- package/server/email/reset-password.html +21 -0
- package/server/email/welcome.html +21 -0
- package/server/models/company.js +76 -0
- package/server/models/user.js +45 -0
- package/server/router.js +355 -0
- package/server.js +20 -0
- package/util.js +1145 -0
- 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
|