@komensal/ui 0.1.0
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/package.json +48 -0
- package/src/components/alert.tsx +45 -0
- package/src/components/auth-header.tsx +26 -0
- package/src/components/button.tsx +121 -0
- package/src/components/command.tsx +125 -0
- package/src/components/dialog.tsx +118 -0
- package/src/components/form-fields/index.ts +5 -0
- package/src/components/form-fields/input-field.tsx +131 -0
- package/src/components/form-fields/label-field.tsx +20 -0
- package/src/components/form-fields/map-picker-field.tsx +49 -0
- package/src/components/form-fields/otp-field.tsx +76 -0
- package/src/components/form-fields/select-field.tsx +77 -0
- package/src/components/form.tsx +138 -0
- package/src/components/google-map-picker.tsx +243 -0
- package/src/components/input.tsx +40 -0
- package/src/components/label.tsx +17 -0
- package/src/components/loading.tsx +19 -0
- package/src/components/otp-input.tsx +62 -0
- package/src/components/password-checklist.tsx +37 -0
- package/src/components/popover.tsx +31 -0
- package/src/components/select-input.tsx +236 -0
- package/src/components/select.tsx +143 -0
- package/src/components/spinner.tsx +61 -0
- package/src/components/step-header.tsx +16 -0
- package/src/components/stepper.tsx +69 -0
- package/src/components/toast/index.ts +2 -0
- package/src/components/toast/toast-provider.tsx +123 -0
- package/src/components/toast/toast.tsx +106 -0
- package/src/components/tooltip.tsx +56 -0
- package/src/components/wrapper.tsx +12 -0
- package/src/index.ts +27 -0
- package/src/locales/es/ui.json +28 -0
- package/src/locales/index.ts +7 -0
- package/src/styles/index.css +1 -0
- package/src/styles/theme.css +105 -0
- package/src/types/password.ts +12 -0
- package/src/types/toast.ts +19 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/styles.ts +19 -0
- package/src/utils/toast-emitter.ts +37 -0
- package/src/vite-env.d.ts +3 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Control, FieldValues, Path } from 'react-hook-form'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../utils'
|
|
4
|
+
import { FormControl, FormField, FormItem, FormMessage } from '../form'
|
|
5
|
+
import { GoogleMapPicker, type GoogleMapPickerProps } from '../google-map-picker'
|
|
6
|
+
import { LabelField } from './label-field'
|
|
7
|
+
|
|
8
|
+
type MapPickerFieldProps<T extends FieldValues> = Omit<GoogleMapPickerProps, 'hasError' | 'onChange' | 'value'> & {
|
|
9
|
+
control: Control<T>
|
|
10
|
+
label?: string
|
|
11
|
+
labelClassName?: string
|
|
12
|
+
mainClassName?: string
|
|
13
|
+
name: Path<T>
|
|
14
|
+
visualRequired?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const MapPickerField = <T extends FieldValues>({
|
|
18
|
+
control,
|
|
19
|
+
label,
|
|
20
|
+
labelClassName,
|
|
21
|
+
mainClassName,
|
|
22
|
+
name,
|
|
23
|
+
visualRequired,
|
|
24
|
+
...pickerProps
|
|
25
|
+
}: MapPickerFieldProps<T>) => (
|
|
26
|
+
<FormField
|
|
27
|
+
control={control}
|
|
28
|
+
name={name}
|
|
29
|
+
render={({ field, fieldState }) => (
|
|
30
|
+
<FormItem className={cn('flex flex-col', mainClassName)}>
|
|
31
|
+
<LabelField
|
|
32
|
+
label={label}
|
|
33
|
+
labelClassName={cn(labelClassName, fieldState.error && 'text-destructive')}
|
|
34
|
+
name={name}
|
|
35
|
+
visualRequired={visualRequired}
|
|
36
|
+
/>
|
|
37
|
+
<FormControl>
|
|
38
|
+
<GoogleMapPicker
|
|
39
|
+
{...pickerProps}
|
|
40
|
+
hasError={!!fieldState.error}
|
|
41
|
+
onChange={field.onChange}
|
|
42
|
+
value={field.value ?? ''}
|
|
43
|
+
/>
|
|
44
|
+
</FormControl>
|
|
45
|
+
<FormMessage />
|
|
46
|
+
</FormItem>
|
|
47
|
+
)}
|
|
48
|
+
/>
|
|
49
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Control, FieldPath, FieldValues } from 'react-hook-form'
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../utils'
|
|
5
|
+
import { FormControl, FormField, FormItem, FormMessage } from '../form'
|
|
6
|
+
import { Label } from '../label'
|
|
7
|
+
import { InputOTP, InputOTPGroup, InputOTPSlot } from '../otp-input'
|
|
8
|
+
|
|
9
|
+
interface OtpInputFormFieldProps<
|
|
10
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
11
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
12
|
+
> {
|
|
13
|
+
autoFocus?: boolean
|
|
14
|
+
className?: string
|
|
15
|
+
containerClassName?: string
|
|
16
|
+
control: Control<TFieldValues>
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
label?: string
|
|
19
|
+
labelClassName?: string
|
|
20
|
+
name: TName
|
|
21
|
+
otpLength?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function OTPField<
|
|
25
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
26
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
27
|
+
>({
|
|
28
|
+
autoFocus,
|
|
29
|
+
className,
|
|
30
|
+
containerClassName,
|
|
31
|
+
control,
|
|
32
|
+
disabled,
|
|
33
|
+
label,
|
|
34
|
+
labelClassName,
|
|
35
|
+
name,
|
|
36
|
+
otpLength = 6
|
|
37
|
+
}: OtpInputFormFieldProps<TFieldValues, TName>) {
|
|
38
|
+
return (
|
|
39
|
+
<FormField
|
|
40
|
+
control={control}
|
|
41
|
+
name={name}
|
|
42
|
+
render={({ field, fieldState }) => (
|
|
43
|
+
<FormItem className={cn('flex flex-col', className)}>
|
|
44
|
+
{label ? (
|
|
45
|
+
<Label className={cn('text-start text-sm font-semibold', labelClassName)} htmlFor={name}>
|
|
46
|
+
{label}
|
|
47
|
+
</Label>
|
|
48
|
+
) : null}
|
|
49
|
+
<FormControl>
|
|
50
|
+
<InputOTP
|
|
51
|
+
aria-invalid={fieldState.invalid || undefined}
|
|
52
|
+
autoComplete='one-time-code'
|
|
53
|
+
autoFocus={autoFocus}
|
|
54
|
+
containerClassName={cn('justify-center', containerClassName)}
|
|
55
|
+
disabled={disabled}
|
|
56
|
+
id={name}
|
|
57
|
+
inputMode='numeric'
|
|
58
|
+
maxLength={otpLength}
|
|
59
|
+
onChange={(val: string) => field.onChange(val)}
|
|
60
|
+
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => field.onChange(e.clipboardData.getData('text'))}
|
|
61
|
+
pattern='[0-9]*'
|
|
62
|
+
value={field.value ?? ''}
|
|
63
|
+
>
|
|
64
|
+
<InputOTPGroup>
|
|
65
|
+
{Array.from({ length: otpLength }).map((_, i) => (
|
|
66
|
+
<InputOTPSlot aria-invalid={fieldState.invalid || undefined} index={i} key={i} />
|
|
67
|
+
))}
|
|
68
|
+
</InputOTPGroup>
|
|
69
|
+
</InputOTP>
|
|
70
|
+
</FormControl>
|
|
71
|
+
<FormMessage />
|
|
72
|
+
</FormItem>
|
|
73
|
+
)}
|
|
74
|
+
/>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Control, FieldValues, Path } from 'react-hook-form'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../utils'
|
|
4
|
+
import { FormControl, FormField, FormItem, FormMessage } from '../form'
|
|
5
|
+
import { SelectInput } from '../select-input'
|
|
6
|
+
import { LabelField } from './label-field'
|
|
7
|
+
|
|
8
|
+
export interface Options {
|
|
9
|
+
label: string
|
|
10
|
+
value: number | string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SelectFieldProps<T extends FieldValues = FieldValues> {
|
|
14
|
+
ableDeleteOption?: boolean
|
|
15
|
+
className?: string
|
|
16
|
+
control: Control<T>
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
label?: string
|
|
19
|
+
labelClassName?: string
|
|
20
|
+
mainClassName?: string
|
|
21
|
+
name: Path<T>
|
|
22
|
+
onChange?: (...event: unknown[]) => void
|
|
23
|
+
options: Options[] | undefined
|
|
24
|
+
placeholder?: string
|
|
25
|
+
search?: boolean
|
|
26
|
+
visualRequired?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const SelectField = <T extends FieldValues = FieldValues>({
|
|
30
|
+
ableDeleteOption,
|
|
31
|
+
className,
|
|
32
|
+
control,
|
|
33
|
+
disabled,
|
|
34
|
+
label,
|
|
35
|
+
labelClassName,
|
|
36
|
+
mainClassName,
|
|
37
|
+
name,
|
|
38
|
+
options,
|
|
39
|
+
placeholder,
|
|
40
|
+
search,
|
|
41
|
+
visualRequired
|
|
42
|
+
}: SelectFieldProps<T>) => {
|
|
43
|
+
return (
|
|
44
|
+
<FormField
|
|
45
|
+
control={control}
|
|
46
|
+
name={name}
|
|
47
|
+
render={({ field, fieldState }) => {
|
|
48
|
+
const hasError = !!fieldState.error
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<FormItem className={cn('flex flex-col', mainClassName)}>
|
|
52
|
+
<LabelField
|
|
53
|
+
label={label}
|
|
54
|
+
labelClassName={cn(labelClassName, hasError && 'text-destructive')}
|
|
55
|
+
name={name}
|
|
56
|
+
visualRequired={visualRequired}
|
|
57
|
+
/>
|
|
58
|
+
<FormControl>
|
|
59
|
+
<SelectInput
|
|
60
|
+
ableDeleteOption={ableDeleteOption}
|
|
61
|
+
className={className}
|
|
62
|
+
disabled={disabled}
|
|
63
|
+
error={fieldState.error}
|
|
64
|
+
items={options}
|
|
65
|
+
onChange={field.onChange}
|
|
66
|
+
placeholder={placeholder}
|
|
67
|
+
search={search}
|
|
68
|
+
value={typeof field.value === 'number' ? `${field.value}` : field.value}
|
|
69
|
+
/>
|
|
70
|
+
</FormControl>
|
|
71
|
+
<FormMessage />
|
|
72
|
+
</FormItem>
|
|
73
|
+
)
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as LabelPrimitive from '@radix-ui/react-label'
|
|
2
|
+
import { Slot } from '@radix-ui/react-slot'
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../utils'
|
|
7
|
+
import { Label } from './label'
|
|
8
|
+
|
|
9
|
+
const Form = FormProvider
|
|
10
|
+
|
|
11
|
+
interface FormFieldContextValue<
|
|
12
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
13
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
14
|
+
> {
|
|
15
|
+
name: TName
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
|
|
19
|
+
|
|
20
|
+
const FormField = <
|
|
21
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
22
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
23
|
+
>({
|
|
24
|
+
...props
|
|
25
|
+
}: ControllerProps<TFieldValues, TName>) => {
|
|
26
|
+
return (
|
|
27
|
+
<FormFieldContext.Provider value={{ name: props.name }}>
|
|
28
|
+
<Controller {...props} />
|
|
29
|
+
</FormFieldContext.Provider>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const useFormField = () => {
|
|
34
|
+
const fieldContext = React.useContext(FormFieldContext)
|
|
35
|
+
const itemContext = React.useContext(FormItemContext)
|
|
36
|
+
const { formState, getFieldState } = useFormContext()
|
|
37
|
+
|
|
38
|
+
if (!fieldContext) {
|
|
39
|
+
throw new Error('useFormField must be used within <FormField>')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!itemContext) {
|
|
43
|
+
throw new Error('useFormField must be used within <FormItem>')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const fieldState = getFieldState(fieldContext.name, formState)
|
|
47
|
+
|
|
48
|
+
const { id } = itemContext
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
formDescriptionId: `${id}-form-item-description`,
|
|
52
|
+
formItemId: `${id}-form-item`,
|
|
53
|
+
formMessageId: `${id}-form-item-message`,
|
|
54
|
+
id,
|
|
55
|
+
name: fieldContext.name,
|
|
56
|
+
...fieldState
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface FormItemContextValue {
|
|
61
|
+
id: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
|
|
65
|
+
|
|
66
|
+
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
67
|
+
({ className, ...props }, ref) => {
|
|
68
|
+
const id = React.useId()
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<FormItemContext.Provider value={{ id }}>
|
|
72
|
+
<div className={cn('space-y-1', className)} ref={ref} {...props} />
|
|
73
|
+
</FormItemContext.Provider>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
FormItem.displayName = 'FormItem'
|
|
78
|
+
|
|
79
|
+
const FormLabel = React.forwardRef<
|
|
80
|
+
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
81
|
+
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
|
82
|
+
>(({ className, ...props }, ref) => {
|
|
83
|
+
const { error, formItemId } = useFormField()
|
|
84
|
+
|
|
85
|
+
return <Label className={cn(error && 'text-destructive', className)} htmlFor={formItemId} ref={ref} {...props} />
|
|
86
|
+
})
|
|
87
|
+
FormLabel.displayName = 'FormLabel'
|
|
88
|
+
|
|
89
|
+
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
|
|
90
|
+
({ ...props }, ref) => {
|
|
91
|
+
const { error, formDescriptionId, formItemId, formMessageId } = useFormField()
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Slot
|
|
95
|
+
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
|
96
|
+
aria-invalid={!!error}
|
|
97
|
+
id={formItemId}
|
|
98
|
+
ref={ref}
|
|
99
|
+
{...props}
|
|
100
|
+
/>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
FormControl.displayName = 'FormControl'
|
|
105
|
+
|
|
106
|
+
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
|
107
|
+
({ className, ...props }, ref) => {
|
|
108
|
+
const { formDescriptionId } = useFormField()
|
|
109
|
+
|
|
110
|
+
return <p className={cn('text-muted text-[0.8rem]', className)} id={formDescriptionId} ref={ref} {...props} />
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
FormDescription.displayName = 'FormDescription'
|
|
114
|
+
|
|
115
|
+
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
|
116
|
+
({ children, className, ...props }, ref) => {
|
|
117
|
+
const { error, formMessageId } = useFormField()
|
|
118
|
+
const body = error?.message ? String(error.message) : children
|
|
119
|
+
|
|
120
|
+
if (!body) {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<p
|
|
126
|
+
className={cn('text-destructive text-[0.8rem] font-medium', className)}
|
|
127
|
+
id={formMessageId}
|
|
128
|
+
ref={ref}
|
|
129
|
+
{...props}
|
|
130
|
+
>
|
|
131
|
+
{body}
|
|
132
|
+
</p>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
FormMessage.displayName = 'FormMessage'
|
|
137
|
+
|
|
138
|
+
export { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useFormField }
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { MapMouseEvent } from '@vis.gl/react-google-maps'
|
|
2
|
+
|
|
3
|
+
import { AdvancedMarker, APIProvider, Map, useMap, useMapsLibrary } from '@vis.gl/react-google-maps'
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
5
|
+
|
|
6
|
+
import { cn, inputBase, inputFocus } from '../utils'
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CENTER = { lat: 4.711, lng: -74.0721 }
|
|
9
|
+
const PICKER_MAP_ID = 'komensal-restaurant-map'
|
|
10
|
+
const SELECTED_ZOOM = 15
|
|
11
|
+
const DEBOUNCE_MS = 300
|
|
12
|
+
|
|
13
|
+
export interface GoogleMapPickerProps {
|
|
14
|
+
centerOn?: google.maps.LatLngLiteral
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
hasError?: boolean
|
|
17
|
+
locationQuery?: string
|
|
18
|
+
locationZoom?: number
|
|
19
|
+
onChange: (address: string) => void
|
|
20
|
+
placeholder?: string
|
|
21
|
+
value: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CITY_ZOOM = 12
|
|
25
|
+
|
|
26
|
+
const MapPickerContent = ({
|
|
27
|
+
centerOn,
|
|
28
|
+
disabled,
|
|
29
|
+
hasError,
|
|
30
|
+
locationQuery,
|
|
31
|
+
locationZoom,
|
|
32
|
+
onChange,
|
|
33
|
+
placeholder,
|
|
34
|
+
value
|
|
35
|
+
}: GoogleMapPickerProps) => {
|
|
36
|
+
const placesLib = useMapsLibrary('places')
|
|
37
|
+
const geocodingLib = useMapsLibrary('geocoding')
|
|
38
|
+
const map = useMap(PICKER_MAP_ID)
|
|
39
|
+
const onChangeRef = useRef(onChange)
|
|
40
|
+
const geocoder = useMemo<google.maps.Geocoder | null>(
|
|
41
|
+
() => (geocodingLib ? new geocodingLib.Geocoder() : null),
|
|
42
|
+
[geocodingLib]
|
|
43
|
+
)
|
|
44
|
+
const debounceRef = useRef<null | ReturnType<typeof setTimeout>>(null)
|
|
45
|
+
|
|
46
|
+
const [inputValue, setInputValue] = useState(value)
|
|
47
|
+
const [suggestions, setSuggestions] = useState<google.maps.places.AutocompleteSuggestion[]>([])
|
|
48
|
+
const [showDropdown, setShowDropdown] = useState(false)
|
|
49
|
+
const [sessionToken, setSessionToken] = useState<google.maps.places.AutocompleteSessionToken | undefined>()
|
|
50
|
+
const [position, setPosition] = useState<google.maps.LatLngLiteral | null>(null)
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
onChangeRef.current = onChange
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
setInputValue(value)
|
|
58
|
+
}, [value])
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (map && centerOn) {
|
|
62
|
+
map.panTo(centerOn)
|
|
63
|
+
map.setZoom(CITY_ZOOM)
|
|
64
|
+
}
|
|
65
|
+
}, [map, centerOn])
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (map && position) {
|
|
69
|
+
map.panTo(position)
|
|
70
|
+
map.setZoom(SELECTED_ZOOM)
|
|
71
|
+
}
|
|
72
|
+
}, [map, position])
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!placesLib) return
|
|
76
|
+
setSessionToken(new placesLib.AutocompleteSessionToken())
|
|
77
|
+
}, [placesLib])
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!geocoder || !locationQuery || !map) return
|
|
81
|
+
const g = geocoder
|
|
82
|
+
const ctrl = { cancelled: false }
|
|
83
|
+
void (async () => {
|
|
84
|
+
try {
|
|
85
|
+
const { results } = await g.geocode({ address: locationQuery })
|
|
86
|
+
if (ctrl.cancelled || results.length === 0) return
|
|
87
|
+
const loc = results[0].geometry.location
|
|
88
|
+
map.panTo({ lat: loc.lat(), lng: loc.lng() })
|
|
89
|
+
map.setZoom(locationZoom ?? CITY_ZOOM)
|
|
90
|
+
} catch {
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
})()
|
|
94
|
+
return () => {
|
|
95
|
+
ctrl.cancelled = true
|
|
96
|
+
}
|
|
97
|
+
}, [geocoder, locationQuery, locationZoom, map])
|
|
98
|
+
|
|
99
|
+
const fetchSuggestions = useCallback(
|
|
100
|
+
async (input: string) => {
|
|
101
|
+
if (!placesLib || input.length < 2) {
|
|
102
|
+
setSuggestions([])
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const result = await placesLib.AutocompleteSuggestion.fetchAutocompleteSuggestions({
|
|
107
|
+
input,
|
|
108
|
+
sessionToken
|
|
109
|
+
})
|
|
110
|
+
setSuggestions(result.suggestions)
|
|
111
|
+
setShowDropdown(true)
|
|
112
|
+
} catch {
|
|
113
|
+
setSuggestions([])
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
[placesLib, sessionToken]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const reverseGeocode = useCallback(
|
|
120
|
+
async (latLng: google.maps.LatLngLiteral) => {
|
|
121
|
+
if (!geocoder) return
|
|
122
|
+
try {
|
|
123
|
+
const { results } = await geocoder.geocode({ location: latLng })
|
|
124
|
+
if (results.length > 0) {
|
|
125
|
+
const address = results[0].formatted_address
|
|
126
|
+
setInputValue(address)
|
|
127
|
+
onChangeRef.current(address)
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
[geocoder]
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
137
|
+
const val = e.target.value
|
|
138
|
+
setInputValue(val)
|
|
139
|
+
onChangeRef.current(val)
|
|
140
|
+
|
|
141
|
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
142
|
+
debounceRef.current = setTimeout(() => void fetchSuggestions(val), DEBOUNCE_MS)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const handleSelect = async (suggestion: google.maps.places.AutocompleteSuggestion) => {
|
|
146
|
+
const prediction = suggestion.placePrediction
|
|
147
|
+
if (!prediction) return
|
|
148
|
+
|
|
149
|
+
const place = prediction.toPlace()
|
|
150
|
+
await place.fetchFields({ fields: ['formattedAddress', 'location'] })
|
|
151
|
+
|
|
152
|
+
const address = place.formattedAddress ?? prediction.text.text
|
|
153
|
+
setInputValue(address)
|
|
154
|
+
onChangeRef.current(address)
|
|
155
|
+
setSuggestions([])
|
|
156
|
+
setShowDropdown(false)
|
|
157
|
+
|
|
158
|
+
if (place.location) {
|
|
159
|
+
setPosition({ lat: place.location.lat(), lng: place.location.lng() })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (placesLib) setSessionToken(new placesLib.AutocompleteSessionToken())
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const handleBlur = () => {
|
|
166
|
+
setTimeout(() => setShowDropdown(false), 150)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const handleMapClick = useCallback(
|
|
170
|
+
(e: MapMouseEvent) => {
|
|
171
|
+
if (disabled || !e.detail.latLng) return
|
|
172
|
+
const latLng = e.detail.latLng
|
|
173
|
+
setPosition(latLng)
|
|
174
|
+
void reverseGeocode(latLng)
|
|
175
|
+
},
|
|
176
|
+
[disabled, reverseGeocode]
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
const handleMarkerDragEnd = useCallback(
|
|
180
|
+
(e: google.maps.MapMouseEvent) => {
|
|
181
|
+
const latLng = e.latLng
|
|
182
|
+
if (!latLng) return
|
|
183
|
+
const pos = { lat: latLng.lat(), lng: latLng.lng() }
|
|
184
|
+
setPosition(pos)
|
|
185
|
+
void reverseGeocode(pos)
|
|
186
|
+
},
|
|
187
|
+
[reverseGeocode]
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className='flex flex-col gap-3'>
|
|
192
|
+
<div className='relative'>
|
|
193
|
+
<input
|
|
194
|
+
className={cn(inputBase, inputFocus, hasError && 'border-destructive')}
|
|
195
|
+
disabled={disabled}
|
|
196
|
+
onBlur={handleBlur}
|
|
197
|
+
onChange={handleInputChange}
|
|
198
|
+
onFocus={() => suggestions.length > 0 && setShowDropdown(true)}
|
|
199
|
+
placeholder={placeholder}
|
|
200
|
+
type='text'
|
|
201
|
+
value={inputValue}
|
|
202
|
+
/>
|
|
203
|
+
|
|
204
|
+
{showDropdown && suggestions.length > 0 && (
|
|
205
|
+
<ul className='border-border bg-surface absolute z-50 mt-1 w-full overflow-hidden rounded-[10px] border shadow-md'>
|
|
206
|
+
{suggestions.map((suggestion) => (
|
|
207
|
+
<li key={suggestion.placePrediction?.placeId ?? suggestion.placePrediction?.text.text}>
|
|
208
|
+
<button
|
|
209
|
+
className='text-foreground hover:bg-hover w-full px-4 py-3 text-left text-sm transition-colors'
|
|
210
|
+
onClick={() => void handleSelect(suggestion)}
|
|
211
|
+
type='button'
|
|
212
|
+
>
|
|
213
|
+
{suggestion.placePrediction?.text.text ?? ''}
|
|
214
|
+
</button>
|
|
215
|
+
</li>
|
|
216
|
+
))}
|
|
217
|
+
</ul>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div className='border-border h-59 overflow-hidden rounded-[10px] border md:h-87.5'>
|
|
222
|
+
<Map
|
|
223
|
+
defaultCenter={DEFAULT_CENTER}
|
|
224
|
+
defaultZoom={5}
|
|
225
|
+
disableDefaultUI
|
|
226
|
+
gestureHandling='cooperative'
|
|
227
|
+
id={PICKER_MAP_ID}
|
|
228
|
+
mapId={(import.meta.env.VITE_GOOGLE_MAPS_MAP_ID as string | undefined) ?? 'DEMO_MAP_ID'}
|
|
229
|
+
onClick={handleMapClick}
|
|
230
|
+
style={{ height: '100%', width: '100%' }}
|
|
231
|
+
>
|
|
232
|
+
{position && <AdvancedMarker draggable={!disabled} onDragEnd={handleMarkerDragEnd} position={position} />}
|
|
233
|
+
</Map>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export const GoogleMapPicker = (props: GoogleMapPickerProps) => (
|
|
240
|
+
<APIProvider apiKey={import.meta.env.VITE_GOOGLE_MAPS_API_KEY ?? ''} language='es' region='CO'>
|
|
241
|
+
<MapPickerContent {...props} />
|
|
242
|
+
</APIProvider>
|
|
243
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
import { cn, inputBase, inputFocus } from '../utils'
|
|
4
|
+
|
|
5
|
+
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
|
6
|
+
|
|
7
|
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
|
8
|
+
return (
|
|
9
|
+
<input
|
|
10
|
+
className={cn(
|
|
11
|
+
inputBase,
|
|
12
|
+
inputFocus,
|
|
13
|
+
'file:border-0 file:bg-transparent file:text-base file:font-medium',
|
|
14
|
+
className
|
|
15
|
+
)}
|
|
16
|
+
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
17
|
+
const key = e.key
|
|
18
|
+
const allowedKeys = ['Backspace', 'Tab', 'Enter', 'Escape', 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown']
|
|
19
|
+
if (allowedKeys.includes(key)) {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
if (!(key >= '0' && key <= '9') && type === 'number') {
|
|
23
|
+
e.preventDefault()
|
|
24
|
+
}
|
|
25
|
+
if (type === 'onlyText') {
|
|
26
|
+
const regex = /^[a-zA-ZáéíóúÁÉÍÓÚñÑ ]*$/
|
|
27
|
+
if (!regex.test(key)) {
|
|
28
|
+
e.preventDefault()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}}
|
|
32
|
+
ref={ref}
|
|
33
|
+
type={type}
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
Input.displayName = 'Input'
|
|
39
|
+
|
|
40
|
+
export { Input }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as LabelPrimitive from '@radix-ui/react-label'
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../utils'
|
|
6
|
+
|
|
7
|
+
const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70')
|
|
8
|
+
|
|
9
|
+
const Label = React.forwardRef<
|
|
10
|
+
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
11
|
+
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
|
12
|
+
>(({ className, ...props }, ref) => (
|
|
13
|
+
<LabelPrimitive.Root className={cn(labelVariants(), className)} ref={ref} {...props} />
|
|
14
|
+
))
|
|
15
|
+
Label.displayName = LabelPrimitive.Root.displayName
|
|
16
|
+
|
|
17
|
+
export { Label }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cn } from '../utils'
|
|
2
|
+
import { Spinner } from './spinner'
|
|
3
|
+
|
|
4
|
+
interface LoadingProps {
|
|
5
|
+
className?: string
|
|
6
|
+
isSpinner?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Loading({ className = '', isSpinner = true }: Readonly<LoadingProps>) {
|
|
10
|
+
return (
|
|
11
|
+
<div className={cn('relative flex min-h-screen items-center justify-center gap-6', className)}>
|
|
12
|
+
{isSpinner && (
|
|
13
|
+
<div className='absolute right-0 bottom-0 m-10'>
|
|
14
|
+
<Spinner />
|
|
15
|
+
</div>
|
|
16
|
+
)}
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|