nitro-web 0.0.87 → 0.0.89

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 (35) hide show
  1. package/client/index.ts +0 -3
  2. package/components/auth/auth.api.js +411 -0
  3. package/components/auth/reset.tsx +86 -0
  4. package/components/auth/signin.tsx +76 -0
  5. package/components/auth/signup.tsx +62 -0
  6. package/components/billing/stripe.api.js +268 -0
  7. package/components/dashboard/dashboard.tsx +32 -0
  8. package/components/partials/element/accordion.tsx +102 -0
  9. package/components/partials/element/avatar.tsx +40 -0
  10. package/components/partials/element/button.tsx +98 -0
  11. package/components/partials/element/calendar.tsx +125 -0
  12. package/components/partials/element/dropdown.tsx +248 -0
  13. package/components/partials/element/filters.tsx +194 -0
  14. package/components/partials/element/github-link.tsx +16 -0
  15. package/components/partials/element/initials.tsx +66 -0
  16. package/components/partials/element/message.tsx +141 -0
  17. package/components/partials/element/modal.tsx +90 -0
  18. package/components/partials/element/sidebar.tsx +195 -0
  19. package/components/partials/element/tooltip.tsx +154 -0
  20. package/components/partials/element/topbar.tsx +15 -0
  21. package/components/partials/form/checkbox.tsx +150 -0
  22. package/components/partials/form/drop-handler.tsx +68 -0
  23. package/components/partials/form/drop.tsx +141 -0
  24. package/components/partials/form/field-color.tsx +86 -0
  25. package/components/partials/form/field-currency.tsx +158 -0
  26. package/components/partials/form/field-date.tsx +252 -0
  27. package/components/partials/form/field.tsx +231 -0
  28. package/components/partials/form/form-error.tsx +27 -0
  29. package/components/partials/form/location.tsx +225 -0
  30. package/components/partials/form/select.tsx +360 -0
  31. package/components/partials/is-first-render.ts +14 -0
  32. package/components/partials/not-found.tsx +7 -0
  33. package/components/partials/styleguide.tsx +407 -0
  34. package/package.json +2 -1
  35. package/types/globals.d.ts +0 -1
@@ -0,0 +1,231 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // fill-current tw class for lucide icons (https://github.com/lucide-icons/lucide/discussions/458)
3
+ import { css } from 'twin.macro'
4
+ import { FieldCurrency, FieldCurrencyProps, FieldColor, FieldColorProps, FieldDate, FieldDateProps } from 'nitro-web'
5
+ import { twMerge, getErrorFromState, deepFind } from 'nitro-web/util'
6
+ import { Errors, type Error } from 'nitro-web/types'
7
+ import { MailIcon, CalendarIcon, FunnelIcon, SearchIcon, EyeIcon, EyeOffIcon } from 'lucide-react'
8
+ import { memo } from 'react'
9
+
10
+ type FieldType = 'text' | 'password' | 'email' | 'filter' | 'search' | 'textarea' | 'currency' | 'date' | 'color'
11
+ type InputProps = React.InputHTMLAttributes<HTMLInputElement>
12
+ type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
13
+ type FieldExtraProps = {
14
+ /** field name or path on state (used to match errors), e.g. 'date', 'company.email' */
15
+ name: string
16
+ /** name is applied if id is not provided */
17
+ id?: string
18
+ /** state object to get the value, and check errors against */
19
+ state?: { errors?: Errors, [key: string]: any }
20
+ /** type of the field */
21
+ type?: FieldType
22
+ /** icon to show in the input */
23
+ icon?: React.ReactNode
24
+ iconPos?: 'left' | 'right'
25
+ /** Dependencies to break the implicit memoization of onChange/onInputChange */
26
+ deps?: unknown[]
27
+ placeholder?: string
28
+ /** title used to find related error messages */
29
+ errorTitle?: string|RegExp
30
+ }
31
+ type IconWrapperProps = {
32
+ iconPos: 'left' | 'right'
33
+ icon?: React.ReactNode
34
+ [key: string]: unknown
35
+ }
36
+ // Discriminated union (https://stackoverflow.com/a/77351290/1900648)
37
+ export type FieldProps = (
38
+ | ({ type?: 'text' | 'password' | 'email' | 'filter' | 'search' } & InputProps & FieldExtraProps)
39
+ | ({ type: 'textarea' } & TextareaProps & FieldExtraProps)
40
+ | ({ type: 'currency' } & FieldCurrencyProps & FieldExtraProps)
41
+ | ({ type: 'color' } & FieldColorProps & FieldExtraProps)
42
+ | ({ type: 'date' } & FieldDateProps & FieldExtraProps)
43
+ )
44
+ type IsFieldCachedProps = {
45
+ name: string
46
+ state?: FieldProps['state']
47
+ deps?: FieldProps['deps']
48
+ errorTitle?: FieldProps['errorTitle']
49
+ }
50
+
51
+ export const Field = memo(FieldBase, (prev, next) => {
52
+ return isFieldCached(prev, next)
53
+ })
54
+
55
+ function FieldBase({ state, icon, iconPos: ip, errorTitle, ...props }: FieldProps) {
56
+ // `type` must be kept as props.type for TS to be happy and follow the conditions below
57
+ let value!: string
58
+ let Icon!: React.ReactNode
59
+ const error = getErrorFromState(state, errorTitle || props.name)
60
+ const type = props.type
61
+ const iconPos = ip == 'left' || (type == 'color' && !ip) ? 'left' : 'right'
62
+ const id = props.id || props.name
63
+
64
+ if (!props.name) {
65
+ throw new Error('Field component requires a `name` prop')
66
+ }
67
+
68
+ // Input type
69
+ const [inputType, setInputType] = useState(() => { // eslint-disable-line
70
+ return type == 'password' ? 'password' : (type == 'textarea' ? 'textarea' : 'text')
71
+ })
72
+
73
+ // Value: Input is always controlled if state is passed in
74
+ if (typeof props.value !== 'undefined') value = props.value as string
75
+ else if (typeof state == 'object') {
76
+ const v = deepFind(state, props.name) as string | undefined
77
+ value = v ?? ''
78
+ }
79
+
80
+ // Icon
81
+ if (type == 'password') {
82
+ Icon = <IconWrapper
83
+ iconPos={iconPos}
84
+ icon={icon || inputType == 'password' ? <EyeOffIcon /> : <EyeIcon />}
85
+ onClick={() => setInputType(o => o == 'password' ? 'text' : 'password')}
86
+ className="size-[15px] size-input-icon pointer-events-auto"
87
+ />
88
+ } else if (type == 'email') {
89
+ Icon = <IconWrapper iconPos={iconPos} icon={icon || <MailIcon />} className="size-[14px] size-input-icon" />
90
+ } else if (type == 'filter') {
91
+ Icon = <IconWrapper iconPos={iconPos} icon={icon || <FunnelIcon />} className="size-[14px] size-input-icon" />
92
+ } else if (type == 'search') {
93
+ Icon = <IconWrapper iconPos={iconPos} icon={icon || <SearchIcon />} className="size-[14px] size-input-icon" />
94
+ } else if (type == 'color') {
95
+ Icon = <IconWrapper iconPos={iconPos} icon={icon || <ColorSvg hex={value}/>} className="size-[17px]" />
96
+ } else if (type == 'date') {
97
+ Icon = <IconWrapper iconPos={iconPos} icon={icon || <CalendarIcon />} className="size-[14px] size-input-icon" />
98
+ } else if (icon) {
99
+ Icon = <IconWrapper iconPos={iconPos} icon={icon} className="size-[14px] size-input-icon" />
100
+ }
101
+
102
+ // Classname
103
+ const inputClassName = getInputClasses({ error, Icon, iconPos, type })
104
+ const commonProps = { id: id, value: value, className: inputClassName }
105
+
106
+ // Type has to be referenced as props.type for TS to be happy
107
+ if (!type || type == 'text' || type == 'password' || type == 'email' || type == 'filter' || type == 'search') {
108
+ return (
109
+ <FieldContainer error={error} className={props.className}>
110
+ {Icon}<input {...props} {...commonProps} type={inputType} />
111
+ </FieldContainer>
112
+ )
113
+ } else if (type == 'textarea') {
114
+ return (
115
+ <FieldContainer error={error} className={props.className}>
116
+ {Icon}<textarea {...props} {...commonProps} />
117
+ </FieldContainer>
118
+ )
119
+ } else if (type == 'currency') {
120
+ return (
121
+ <FieldContainer error={error} className={props.className}>
122
+ {Icon}<FieldCurrency {...props} {...commonProps} />
123
+ </FieldContainer>
124
+ )
125
+ } else if (type == 'color') {
126
+ return (
127
+ <FieldContainer error={error} className={props.className}>
128
+ <FieldColor {...props} {...commonProps} Icon={Icon} />
129
+ </FieldContainer>
130
+ )
131
+ } else if (type == 'date') {
132
+ return (
133
+ <FieldContainer error={error} className={props.className}>
134
+ <FieldDate {...props} {...commonProps} Icon={Icon} />
135
+ </FieldContainer>
136
+ )
137
+ }
138
+ }
139
+
140
+ function FieldContainer({ children, className, error }: { children: React.ReactNode, className?: string, error?: Error }) {
141
+ return (
142
+ <div css={style} className={'mt-2.5 mb-6 ' + twMerge(`mt-input-before mb-input-after grid grid-cols-1 nitro-field ${className || ''}`)}>
143
+ {children}
144
+ {error && <div class="mt-1.5 text-xs text-danger-foreground nitro-error">{error.detail}</div>}
145
+ </div>
146
+ )
147
+ }
148
+
149
+ function getInputClasses({ error, Icon, iconPos, type }: { error?: Error, Icon?: React.ReactNode, iconPos: string, type?: string }) {
150
+ // not twMerge
151
+ const px = 'px-[12px]'
152
+ const py = 'py-[9px] py-input-y'
153
+ return (
154
+ 'block col-start-1 row-start-1 w-full rounded-md bg-white text-input-base outline outline-1 -outline-offset-1 ' +
155
+ 'placeholder:text-input-placeholder focus:outline focus:outline-2 focus:-outline-offset-2 ' + `${py} ${px} ` +
156
+ (iconPos == 'right' && Icon ? 'pr-[32px] pr-input-x-icon pl-input-x ' : '') +
157
+ (iconPos == 'left' && Icon ? 'pl-[32px] pl-input-x-icon pr-input-x ' : 'px-input-x ') +
158
+ (iconPos == 'left' && Icon && type == 'color' ? 'indent-[5px] ' : '') +
159
+ (error
160
+ ? 'text-danger-foreground outline-danger focus:outline-danger '
161
+ : 'text-input outline-input-border focus:outline-input-border-focus ') +
162
+ (iconPos == 'right' ? 'justify-self-start ' : 'justify-self-end ') +
163
+ 'nitro-input'
164
+ )
165
+ }
166
+
167
+ function IconWrapper({ icon, iconPos, ...props }: IconWrapperProps) {
168
+ return (
169
+ !!icon &&
170
+ <div
171
+ {...props}
172
+ className={
173
+ 'z-[0] col-start-1 row-start-1 self-center text-[#c6c8ce] text-input-icon select-none [&>svg]:size-full ' +
174
+ (iconPos == 'right' ? 'justify-self-end mr-[12px] mr-input-x ' : 'justify-self-start ml-[12px] ml-input-x ') +
175
+ props.className || ''
176
+ }
177
+ >{icon}</div>
178
+ )
179
+ }
180
+
181
+ function ColorSvg({ hex }: { hex?: string }) {
182
+ return (
183
+ <span class="block size-full rounded-md" style={{ backgroundColor: hex ? hex : '#f1f1f1' }}></span>
184
+ )
185
+ }
186
+
187
+ export function isFieldCached(prev: IsFieldCachedProps, next: IsFieldCachedProps) {
188
+ // Check if the field is cached, onChange/onInputChange doesn't affect the cache
189
+ const path = prev.name
190
+ const prevState = prev.state || {}
191
+ const nextState = next.state || {}
192
+ const errorTitle = next.errorTitle || path
193
+
194
+ // Check if any prop has changed, except `onChange`/`onInputChange`
195
+ const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)])
196
+ for (const k of allKeys) {
197
+ if (k === 'state' || k === 'onChange' || k === 'onInputChange') continue
198
+ if (prev[k as keyof typeof prev] !== next[k as keyof typeof next]) {
199
+ // console.log(4, 'changed', path, k)
200
+ return false
201
+ }
202
+ }
203
+
204
+ // If `deps` have changed, handy for onChange/onInputChange, re-render!
205
+ if ((next.deps?.length !== prev.deps?.length) || next.deps?.some((v, i) => v !== prev.deps?.[i])) return false
206
+
207
+ // If the state value has changed, re-render!
208
+ if (deepFind(prevState, path) !== deepFind(nextState, path)) return false
209
+
210
+ // If the state error has changed, re-render!
211
+ if (getErrorFromState(prevState, errorTitle) !== getErrorFromState(nextState, errorTitle)) return false
212
+
213
+ // All good, use cached version
214
+ return true
215
+ }
216
+
217
+ const style = css`
218
+ input {
219
+ appearance: textfield;
220
+ -moz-appearance: textfield;
221
+ }
222
+ input::-webkit-outer-spin-button,
223
+ input::-webkit-inner-spin-button {
224
+ -webkit-appearance: none;
225
+ margin: 0;
226
+ }
227
+ /* tw4 we can use calc to determine the padding-left with css variables...
228
+ .inputt {
229
+ padding-left: calc(var(--input-x) * 2);
230
+ } */
231
+ `
@@ -0,0 +1,27 @@
1
+ import { Errors } from 'nitro-web/types'
2
+
3
+ type FormError = {
4
+ state: { errors?: Errors },
5
+ // display all errors except these field titles, e.g. ['name', 'address']
6
+ fields?: Array<string>,
7
+ className?: string,
8
+ }
9
+
10
+ export function FormError({ state, fields, className }: FormError) {
11
+ // A catch all error element that should be placed next to the submit button
12
+ let error: { title: string, detail: string } | undefined
13
+ for (const item of state.errors || []) {
14
+ if (!item.title || item.title.match(/^(error|invalid)$/i) || (fields && !fields.includes(item.title))) {
15
+ error = item
16
+ }
17
+ }
18
+ return (
19
+ <>
20
+ {error ? (
21
+ <div class={`text-danger-foreground mt-1 text-sm nitro-error ${className||''}`}>
22
+ {error.detail}
23
+ </div>
24
+ ) : null}
25
+ </>
26
+ )
27
+ }
@@ -0,0 +1,225 @@
1
+ // @ts-nocheck
2
+ // todo: finish tailwind conversion
3
+ import * as util from 'nitro-web/util'
4
+
5
+ type LocationProps = {
6
+ clear: boolean
7
+ id?: string
8
+ name: string
9
+ onInput?: (place: Place) => void
10
+ onSelect?: (place: Place) => void
11
+ placeholder?: string
12
+ placeTypes?: string[]
13
+ value?: Place
14
+ googleMapsApiKey: string
15
+ }
16
+
17
+ export function Location({ clear, id, name, onInput, onSelect, placeholder, placeTypes, value, googleMapsApiKey }: LocationProps) {
18
+ /**
19
+ * Get location or area of place (requires both 'maps javascript' and 'places' APIs)
20
+ *
21
+ * @param {boolean} clear - clear input after select
22
+ * @param {function(place)} onInput - called when the input value changes, with an
23
+ * empty place, e.g. {full: '...', fullModified: true}
24
+ * @param {function(place)} onSelect - called when a place is selected
25
+ * @param {object} value - {full, line1, ..etc}
26
+ *
27
+ * Handy box tester (see also util.mongoAddKmsToBox())
28
+ * https://www.keene.edu/campus/maps/tool/
29
+ *
30
+ * Returned Google places viewport (area), i.e. `place.geometry.viewport`
31
+ * {
32
+ * Qa: {g: 174.4438160493033, h: 174.9684260722261} == [btmLng, topLng]
33
+ * zb: {g: -37.05901990116617, h: -36.66060184426172} == [btmLat, topLat]
34
+ * }
35
+ */
36
+ const inputRef = useRef(null)
37
+ const full = (value || {}).full || ''
38
+ const [inputValue, setInputValue] = useState(full)
39
+
40
+ useEffect(() => {
41
+ if (!onSelect) console.error('Please pass `onSelect` to location.jsx')
42
+ let autoComplete
43
+ loadGoogleMaps(googleMapsApiKey).then(() => {
44
+ if (inputRef.current) {
45
+ autoComplete = new window.google.maps.places.Autocomplete(inputRef.current, {
46
+ types: placeTypes ? placeTypes : ['address'],
47
+ componentRestrictions: { country: ['nz'] },
48
+ })
49
+ autoComplete.setFields(['address_components', 'formatted_address', 'geometry'])
50
+ autoComplete.addListener('place_changed', onPlaceSelect)
51
+ inputRef.current.addEventListener('keydown', onKeyDown)
52
+ }
53
+ })
54
+ return () => {
55
+ // It seems like autoComplete cleans up both listeners, handy links if needing to remove sooner..
56
+ // Cleanup listners: https://stackoverflow.com/a/22862011/1900648
57
+ // Cleanup .pac-container: https://stackoverflow.com/a/21419890/1900648
58
+ for (const elem of document.getElementsByClassName('pac-container')) elem.remove()
59
+ }
60
+ }, [])
61
+
62
+ useEffect(() => {
63
+ if (full !== inputValue) setInputValue(full)
64
+ }, [full])
65
+
66
+ function formatAddressObject(place) {
67
+ console.log(place)
68
+ var addressMap = {
69
+ city: ['locality'],
70
+ country: ['country'],
71
+ number: ['street_number'],
72
+ postcode: ['postal_code'],
73
+ region: [
74
+ 'administrative_area_level_1',
75
+ 'administrative_area_level_2',
76
+ 'administrative_area_level_3',
77
+ 'administrative_area_level_4',
78
+ 'administrative_area_level_5',
79
+ ],
80
+ street: ['street_address', 'route'],
81
+ suburb: [
82
+ 'sublocality',
83
+ 'sublocality_level_1',
84
+ 'sublocality_level_2',
85
+ 'sublocality_level_3',
86
+ 'sublocality_level_4',
87
+ ],
88
+ unit: ['subpremise'],
89
+ }
90
+ var address = {
91
+ city: '',
92
+ country: '',
93
+ number: '',
94
+ postcode: '',
95
+ region: '',
96
+ street: '',
97
+ suburb: '',
98
+ unit: '',
99
+ }
100
+ place.address_components.forEach((component) => {
101
+ for (var key in addressMap) {
102
+ if (addressMap[key].indexOf(component.types[0]) !== -1) {
103
+ address[key] = component.long_name
104
+ }
105
+ }
106
+ })
107
+ if (!address.city) {
108
+ address.city = address.suburb
109
+ address.suburb = ''
110
+ }
111
+ return address
112
+ }
113
+
114
+ function onPlaceSelect() {
115
+ const place = this.getPlace()
116
+ if (!place.geometry) return
117
+ if (clear) setInputValue('')
118
+ else setInputValue(place.formatted_address)
119
+ const addressObject = formatAddressObject(place)
120
+ onSelect({
121
+ city: addressObject.city,
122
+ country: addressObject.country,
123
+ line1: [[addressObject.unit, addressObject.number].filter(o=>o).join('/'), addressObject.street].join(' '),
124
+ line2: [addressObject.suburb, addressObject.postcode].filter(o=>o).join(', '),
125
+ full: place.formatted_address,
126
+ number: addressObject.number,
127
+ postcode: addressObject.postcode,
128
+ suburb: addressObject.suburb,
129
+ location: {
130
+ coordinates: [place.geometry.location.lng(), place.geometry.location.lat()],
131
+ type: 'Point',
132
+ },
133
+ unit: addressObject.unit,
134
+ area: !util.deepFind(place, 'geometry.viewport') ? undefined : {
135
+ bottomLeft: [
136
+ place.geometry.viewport.getSouthWest().lng(),
137
+ place.geometry.viewport.getSouthWest().lat(),
138
+ ],
139
+ topRight: [
140
+ place.geometry.viewport.getNorthEast().lng(),
141
+ place.geometry.viewport.getNorthEast().lat(),
142
+ ],
143
+ },
144
+ })
145
+ }
146
+
147
+ function onChange(event) {
148
+ // On input change
149
+ setInputValue(event.target.value)
150
+ if (onInput) onInput({
151
+ full: event.target.value,
152
+ fullModified: true,
153
+ })
154
+ }
155
+
156
+ function onFocus(event) {
157
+ // Required to disable the chrome autocomplete, https://stackoverflow.com/a/57131179/4553162
158
+ if (event.target.autocomplete) {
159
+ event.target.autocomplete = 'off'
160
+ }
161
+ }
162
+
163
+ function onKeyDown(event) {
164
+ // Stop form submission if there is a google autocomplete dropdown opened
165
+ let prevented
166
+ if (event.key === 'Enter') {
167
+ for (const el of document.getElementsByClassName('pac-container')) {
168
+ if (el.offsetParent !== null && !prevented) { // google autocomplete opened somewhere
169
+ event.preventDefault()
170
+ prevented = true
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ return (
177
+ <input
178
+ id={id||name}
179
+ name={name||id}
180
+ onChange={onChange}
181
+ onFocus={onFocus}
182
+ placeholder={placeholder}
183
+ ref={inputRef}
184
+ type="text"
185
+ value={inputValue}
186
+ />
187
+ )
188
+ }
189
+
190
+ function loadGoogleMaps(googleMapsApiKey) {
191
+ // Requires both 'maps javascript api' and 'places api' within Goolge Cloud Platform
192
+ if (!window.initMap) {
193
+ window.initMap = () => {/*noop to prevent warning*/}
194
+ }
195
+
196
+ return new Promise((res) => {
197
+ const scriptId = 'googleMapsUrl'
198
+ let script = document.getElementById(scriptId)
199
+ // script not yet inserted
200
+ if (script === null) {
201
+ script = document.createElement('script')
202
+ script.type = 'text/javascript'
203
+ script.id = scriptId
204
+ script.src = `https://maps.googleapis.com/maps/api/js?key=${googleMapsApiKey}`+
205
+ '&libraries=places&callback=initMap'
206
+ script.onload = () => res()
207
+ document.getElementsByTagName('head')[0].appendChild(script)
208
+ // script has already been inserted
209
+ } else {
210
+ // script has already loaded
211
+ if (window.google) {
212
+ res()
213
+ // script hasn't been loaded yet
214
+ } else {
215
+ const cachedCallback = script.onload
216
+ script.onload = () => {
217
+ cachedCallback()
218
+ res()
219
+ }
220
+ }
221
+ }
222
+ })
223
+ }
224
+
225
+ // Styles are in custom.css