Skip to content

Commit 8b84790

Browse files
committed
chore: better ttl form
1 parent e1c420f commit 8b84790

File tree

6 files changed

+245
-40
lines changed

6 files changed

+245
-40
lines changed

package-lock.json

Lines changed: 30 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
},
1818
"dependencies": {
1919
"@hey-api/client-fetch": "^0.8.3",
20+
"@hookform/resolvers": "^4.1.3",
2021
"@radix-ui/react-avatar": "^1.1.3",
2122
"@radix-ui/react-checkbox": "^1.1.4",
2223
"@radix-ui/react-collapsible": "^1.1.3",
@@ -54,7 +55,8 @@
5455
"tailwind-merge": "^3.0.2",
5556
"tailwindcss": "^4.0.13",
5657
"tailwindcss-animate": "^1.0.7",
57-
"vaul": "^1.1.2"
58+
"vaul": "^1.1.2",
59+
"zod": "^3.24.2"
5860
},
5961
"devDependencies": {
6062
"@eslint/js": "^9.22.0",

src/components/GrossToNetDiscountForm.tsx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,11 @@
1-
import { LabelHTMLAttributes, PropsWithChildren, useEffect, useMemo, useState } from "react"
1+
import { useEffect, useMemo, useState } from "react"
22
import { useForm } from "react-hook-form"
33
import Big from "big.js"
4-
import { cn } from "@/lib/utils"
54
import { parseFloatSafe, parseIntSafe } from "@/utils/numbers"
65
import { daysBetween } from "@/utils/dates"
76
import { Act360 } from "@/utils/discount-util"
87
import { Button } from "./ui/button"
9-
10-
type InputContainerProps = PropsWithChildren<{
11-
htmlFor: LabelHTMLAttributes<HTMLLabelElement>["htmlFor"]
12-
label: React.ReactNode
13-
}>
14-
15-
const InputContainer = ({ children, htmlFor, label }: InputContainerProps) => {
16-
return (
17-
<div
18-
className={cn(
19-
"flex gap-2 justify-between items-center font-semibold",
20-
"peer flex h-[58px] w-full rounded-[8px] border bg-elevation-200 px-4 text-sm transition-all duration-200 ease-in-out outline-none focus:outline-none",
21-
"file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:ring-0",
22-
)}
23-
>
24-
<label htmlFor={htmlFor}>{label}</label>
25-
{children}
26-
</div>
27-
)
28-
}
8+
import { InputContainer } from "./InputContainer"
299

3010
interface CurrencyAmount {
3111
value: Big

src/components/InputContainer.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { LabelHTMLAttributes, PropsWithChildren } from "react"
2+
import { cn } from "@/lib/utils"
3+
4+
type InputContainerProps = PropsWithChildren<{
5+
htmlFor: LabelHTMLAttributes<HTMLLabelElement>["htmlFor"]
6+
label: React.ReactNode
7+
}>
8+
9+
const InputContainer = ({ children, htmlFor, label }: InputContainerProps) => {
10+
return (
11+
<div
12+
className={cn(
13+
"flex gap-2 justify-between items-center font-semibold",
14+
"peer flex h-[58px] w-full rounded-[8px] border bg-elevation-200 px-4 text-sm transition-all duration-200 ease-in-out outline-none focus:outline-none",
15+
"file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:ring-0",
16+
)}
17+
>
18+
<label htmlFor={htmlFor}>{label}</label>
19+
{children}
20+
</div>
21+
)
22+
}
23+
24+
export { InputContainer }

src/components/ui/form.tsx

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import * as React from "react"
2+
import * as LabelPrimitive from "@radix-ui/react-label"
3+
import { Slot } from "@radix-ui/react-slot"
4+
import {
5+
Controller,
6+
FormProvider,
7+
useFormContext,
8+
useFormState,
9+
type ControllerProps,
10+
type FieldPath,
11+
type FieldValues,
12+
} from "react-hook-form"
13+
14+
import { cn } from "@/lib/utils"
15+
import { Label } from "@/components/ui/label"
16+
17+
const Form = FormProvider
18+
19+
type FormFieldContextValue<
20+
TFieldValues extends FieldValues = FieldValues,
21+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
22+
> = {
23+
name: TName
24+
}
25+
26+
const FormFieldContext = React.createContext<FormFieldContextValue>(
27+
{} as FormFieldContextValue
28+
)
29+
30+
const FormField = <
31+
TFieldValues extends FieldValues = FieldValues,
32+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
33+
>({
34+
...props
35+
}: ControllerProps<TFieldValues, TName>) => {
36+
return (
37+
<FormFieldContext.Provider value={{ name: props.name }}>
38+
<Controller {...props} />
39+
</FormFieldContext.Provider>
40+
)
41+
}
42+
43+
const useFormField = () => {
44+
const fieldContext = React.useContext(FormFieldContext)
45+
const itemContext = React.useContext(FormItemContext)
46+
const { getFieldState } = useFormContext()
47+
const formState = useFormState({ name: fieldContext.name })
48+
const fieldState = getFieldState(fieldContext.name, formState)
49+
50+
if (!fieldContext) {
51+
throw new Error("useFormField should be used within <FormField>")
52+
}
53+
54+
const { id } = itemContext
55+
56+
return {
57+
id,
58+
name: fieldContext.name,
59+
formItemId: `${id}-form-item`,
60+
formDescriptionId: `${id}-form-item-description`,
61+
formMessageId: `${id}-form-item-message`,
62+
...fieldState,
63+
}
64+
}
65+
66+
type FormItemContextValue = {
67+
id: string
68+
}
69+
70+
const FormItemContext = React.createContext<FormItemContextValue>(
71+
{} as FormItemContextValue
72+
)
73+
74+
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
75+
const id = React.useId()
76+
77+
return (
78+
<FormItemContext.Provider value={{ id }}>
79+
<div
80+
data-slot="form-item"
81+
className={cn("grid gap-2", className)}
82+
{...props}
83+
/>
84+
</FormItemContext.Provider>
85+
)
86+
}
87+
88+
function FormLabel({
89+
className,
90+
...props
91+
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
92+
const { error, formItemId } = useFormField()
93+
94+
return (
95+
<Label
96+
data-slot="form-label"
97+
data-error={!!error}
98+
className={cn("data-[error=true]:text-destructive", className)}
99+
htmlFor={formItemId}
100+
{...props}
101+
/>
102+
)
103+
}
104+
105+
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
106+
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
107+
108+
return (
109+
<Slot
110+
data-slot="form-control"
111+
id={formItemId}
112+
aria-describedby={
113+
!error
114+
? `${formDescriptionId}`
115+
: `${formDescriptionId} ${formMessageId}`
116+
}
117+
aria-invalid={!!error}
118+
{...props}
119+
/>
120+
)
121+
}
122+
123+
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
124+
const { formDescriptionId } = useFormField()
125+
126+
return (
127+
<p
128+
data-slot="form-description"
129+
id={formDescriptionId}
130+
className={cn("text-muted-foreground text-sm", className)}
131+
{...props}
132+
/>
133+
)
134+
}
135+
136+
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
137+
const { error, formMessageId } = useFormField()
138+
const body = error ? String(error?.message ?? "") : props.children
139+
140+
if (!body) {
141+
return null
142+
}
143+
144+
return (
145+
<p
146+
data-slot="form-message"
147+
id={formMessageId}
148+
className={cn("text-destructive text-sm", className)}
149+
{...props}
150+
>
151+
{body}
152+
</p>
153+
)
154+
}
155+
156+
export {
157+
useFormField,
158+
Form,
159+
FormItem,
160+
FormLabel,
161+
FormControl,
162+
FormDescription,
163+
FormMessage,
164+
FormField,
165+
}

src/pages/quotes/QuotePage.tsx

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ import { GrossToNetDiscountForm } from "@/components/GrossToNetDiscountForm"
2626
import Big from "big.js"
2727
import { toast } from "sonner"
2828
import { useForm } from "react-hook-form"
29+
import { Calendar } from "@/components/ui/calendar"
30+
import { InputContainer } from "@/components/InputContainer"
31+
import { addDays } from "date-fns"
2932

3033
function Loader() {
3134
return (
@@ -51,8 +54,8 @@ interface TimeToLiveFormProps {
5154
const TimeToLiveForm = ({ onSubmit, submitButtonText = "Submit" }: TimeToLiveFormProps) => {
5255
const {
5356
watch,
54-
register,
5557
handleSubmit,
58+
setValue,
5659
formState: { isValid, errors },
5760
} = useForm<TimeToLiveFormValues>({
5861
mode: "all",
@@ -75,24 +78,22 @@ const TimeToLiveForm = ({ onSubmit, submitButtonText = "Submit" }: TimeToLiveFor
7578
})
7679
}}
7780
>
78-
<div className="flex flex-col">
79-
<div
80-
className={cn(
81-
"flex gap-2 justify-between items-center font-semibold",
82-
"peer flex h-[58px] w-full rounded-[8px] border bg-elevation-200 px-4 text-sm transition-all duration-200 ease-in-out outline-none focus:outline-none",
83-
"file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:ring-0",
84-
)}
85-
>
86-
<label htmlFor={"ttl"}>Valid until</label>
87-
81+
<div className="flex flex-col items-center">
82+
<InputContainer htmlFor={"ttl"} label={<>Valid until</>}>
8883
<input
8984
id="ttl"
9085
step="1"
91-
type="number"
86+
value={ttl?.toDateString()}
9287
className="bg-transparent text-right focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
93-
{...register("ttl", {
94-
required: true,
95-
})}
88+
readOnly
89+
/>
90+
</InputContainer>
91+
<div className="flex justify-center my-2 rounded-md border w-full">
92+
<Calendar
93+
mode="single"
94+
selected={ttl}
95+
onSelect={(day) => setValue("ttl", day)}
96+
fromDate={addDays(new Date(Date.now()), 1)}
9697
/>
9798
</div>
9899
</div>
@@ -284,7 +285,7 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo
284285
body: {
285286
action: "offer",
286287
discount: result.discount.net.value.div(result.discount.gross.value).toFixed(4),
287-
ttl: "1",
288+
ttl: result.ttl.ttl.getTime().toFixed(0),
288289
},
289290
})
290291
}
@@ -349,6 +350,10 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo
349350
<span className="font-bold">Net amount:</span> {offerFormData?.discount.net.value.round(0).toFixed(0)}{" "}
350351
{offerFormData?.discount.net.currency}
351352
</span>
353+
<span>
354+
<span className="font-bold">Valid until:</span> {offerFormData?.ttl.ttl.toDateString()} (
355+
{offerFormData && humanReadableDuration("en", offerFormData.ttl.ttl)})
356+
</span>
352357
</div>
353358
</OfferConfirmDrawer>
354359
</div>

0 commit comments

Comments
 (0)