add zustard

This commit is contained in:
2025-12-30 14:37:53 -03:00
parent 58068cd463
commit bad7e15f46
15 changed files with 349 additions and 280 deletions

View File

@@ -1,4 +1,4 @@
import { Fragment } from 'react'
import { Fragment, useEffect } from 'react'
import {
Trash2Icon,
PlusIcon,
@@ -10,6 +10,7 @@ import { useParams } from 'react-router'
import { ErrorMessage } from '@hookform/error-message'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { DateTime } from 'luxon'
import { Form } from '@repo/ui/components/ui/form'
import {
@@ -30,7 +31,6 @@ import {
import {
MAX_ITEMS,
formSchema,
type Enrollment,
type Course,
type User
} from '../_.$orgid.enrollments.add/data'
@@ -41,7 +41,7 @@ import { UserPicker } from '../_.$orgid.enrollments.add/user-picker'
import { Summary } from './bulk'
import { currency } from './utils'
import { useWizard } from '@/components/wizard'
import type { Item } from './bulk'
import { useWizardStore } from './store'
const emptyRow = {
user: undefined,
@@ -49,42 +49,24 @@ const emptyRow = {
scheduled_for: undefined
}
const formSchemaAssigned = formSchema.extend({
coupon: z
.object({
code: z.string(),
type: z.enum(['FIXED', 'PERCENT']),
amount: z.number().positive()
})
.optional()
})
type Schema = z.infer<typeof formSchemaAssigned>
type Schema = z.infer<typeof formSchema>
type AssignedProps = {
onSubmit: (value: any) => void | Promise<void>
courses: Promise<{ hits: Course[] }>
enrollments: Enrollment[]
coupon?: object
}
export function Assigned({
courses,
onSubmit,
enrollments,
coupon: couponInit
}: AssignedProps) {
export function Assigned({ courses }: AssignedProps) {
const wizard = useWizard()
const { orgid } = useParams()
const { update, ...state } = useWizardStore()
const form = useForm({
resolver: zodResolver(formSchemaAssigned),
resolver: zodResolver(formSchema),
defaultValues: {
coupon: couponInit,
enrollments: enrollments.length
? enrollments.map((e: any) => ({
enrollments: state.enrollments.length
? state.enrollments.map((e: any) => ({
...e,
scheduled_for: e.scheduled_for
? new Date(e.scheduled_for)
? DateTime.fromISO(e.scheduled_for, { zone: 'local' }).toJSDate()
: undefined
}))
: [emptyRow]
@@ -96,15 +78,10 @@ export function Assigned({
control,
name: 'enrollments'
})
const items = useWatch({
const enrollments = useWatch({
control,
name: 'enrollments'
})
const coupon = useWatch({ control, name: 'coupon' })
const subtotal = items.reduce(
(acc, { course }) => acc + (course?.unit_price || 0),
0
)
const onSearch = async (search: string) => {
const params = new URLSearchParams({ q: search })
@@ -113,24 +90,22 @@ export function Assigned({
return hits
}
const onSubmit_ = async ({ enrollments, coupon }: Schema) => {
const items = Object.values(
enrollments.reduce<Record<string, Item>>((acc, e) => {
const id = e.course.id
acc[id] ??= { course: e.course, quantity: 0 }
acc[id].quantity++
return acc
}, {})
)
await onSubmit({ enrollments, items, coupon })
const onSubmit = async ({ enrollments }: Schema) => {
update({ enrollments })
wizard('payment')
}
useEffect(() => {
const parsed = formSchema.safeParse({ enrollments })
if (parsed.success) {
update(parsed.data)
}
}, [enrollments])
return (
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit_)} className="space-y-4">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid w-full gap-3 lg:grid-cols-[3fr_3fr_2fr_2fr_auto]">
{/* Header */}
<>
@@ -166,7 +141,9 @@ export function Assigned({
{/* Rows */}
{fields.map((field, index) => {
const { unit_price } = items?.[index]?.course || { unit_price: 0 }
const { unit_price } = enrollments?.[index]?.course || {
unit_price: 0
}
return (
<Fragment key={field.id}>
@@ -281,7 +258,7 @@ export function Assigned({
</div>
{/* Summary */}
<Summary {...{ subtotal, coupon, setValue }} />
<Summary />
</div>
<Separator />

View File

@@ -1,4 +1,4 @@
import { Fragment } from 'react'
import { Fragment, useEffect } from 'react'
import {
ArrowRightIcon,
MinusIcon,
@@ -36,9 +36,11 @@ import { MAX_ITEMS, type Course } from '../_.$orgid.enrollments.add/data'
import { Discount, applyDiscount, type Coupon } from './discount'
import { currency } from './utils'
import { useWizard } from '@/components/wizard'
import { useWizardStore } from './store'
const emptyRow = {
course: undefined
course: undefined as any,
quantity: 1
}
const item = z.object({
@@ -57,39 +59,24 @@ const item = z.object({
})
const formSchema = z.object({
items: z.array(item).min(1).max(MAX_ITEMS),
coupon: z
.object({
code: z.string(),
type: z.enum(['FIXED', 'PERCENT']),
amount: z.number().positive()
})
.optional()
items: z.array(item).min(1).max(MAX_ITEMS)
})
type Schema = z.infer<typeof formSchema>
type Schema = z.input<typeof formSchema>
export type Item = z.infer<typeof item>
type BulkProps = {
onSubmit: (value: any) => void | Promise<void>
courses: Promise<{ hits: Course[] }>
items: Item[]
coupon?: Coupon
}
export function Bulk({
courses,
onSubmit,
items: itemsInit,
coupon: couponInit
}: BulkProps) {
export function Bulk({ courses }: BulkProps) {
const wizard = useWizard()
const { update, ...state } = useWizardStore()
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
items: itemsInit?.length ? itemsInit : [emptyRow],
coupon: couponInit
items: state.items.length ? state.items : [emptyRow]
}
})
const {
@@ -109,35 +96,20 @@ export function Bulk({
control,
name: 'items'
})
const coupon = useWatch({ control, name: 'coupon' })
const subtotal = items.reduce(
(acc, { course, quantity }) =>
acc +
(course?.unit_price || 0) *
(Number.isFinite(quantity) && quantity > 0 ? quantity : 1),
0
)
const onSubmit_ = async ({ coupon, ...data }: Schema) => {
const items = Object.values(
data.items.reduce<Record<string, Item>>((acc, item) => {
const id = item.course.id
if (!acc[id]) {
acc[id] = { ...item }
} else {
acc[id].quantity += item.quantity
}
return acc
}, {})
)
await onSubmit({ coupon, items, enrollments: [] })
const onSubmit_ = async ({ items }: Schema) => {
update({ items })
wizard('payment')
}
useEffect(() => {
const parsed = formSchema.safeParse({ items })
if (parsed.success) {
update(parsed.data)
}
}, [items])
return (
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit_)} className="space-y-4">
@@ -316,7 +288,7 @@ export function Bulk({
</Button>
</div>
<Summary {...{ subtotal, coupon, setValue }} />
<Summary />
</div>
<Separator />
@@ -337,17 +309,9 @@ export function Bulk({
)
}
type SummaryProps = {
subtotal: number
coupon?: Coupon
setValue: UseFormSetValue<any>
}
export function Summary({ subtotal, coupon, setValue }: SummaryProps) {
const discount = coupon
? applyDiscount(subtotal, coupon.amount, coupon.type) * -1
: 0
const total = subtotal > 0 ? subtotal + discount : 0
export function Summary() {
const { summary, coupon, update } = useWizardStore()
const { total, discount, subtotal } = summary()
return (
<>
@@ -407,7 +371,7 @@ export function Summary({ subtotal, coupon, setValue }: SummaryProps) {
tabIndex={-1}
variant="ghost"
onClick={() => {
setValue('coupon', null)
update({ coupon: undefined })
}}
>
<XIcon />
@@ -416,7 +380,7 @@ export function Summary({ subtotal, coupon, setValue }: SummaryProps) {
<Discount
disabled={subtotal === 0}
onChange={(coupon) => {
setValue('coupon', coupon)
update({ coupon })
}}
/>
)}

View File

@@ -72,8 +72,6 @@ export function Discount({ onChange, ...props }: DiscountProps) {
discount_type: 'FIXED' | 'PERCENT'
}
console.log(code)
onChange?.({ code, amount, type })
reset()

View File

@@ -34,8 +34,9 @@ import { Currency } from '@repo/ui/components/currency'
import { useWizard } from '@/components/wizard'
import { isName } from '../_.$orgid.users.add/data'
import type { PaymentMethod } from '@repo/ui/routes/orders/data'
import type { WizardState } from './route'
import type { WizardState } from './store'
import { applyDiscount } from './discount'
import { useWizardStore } from './store'
const creditCard = z.object({
holder_name: z
@@ -55,49 +56,42 @@ const creditCard = z.object({
cvv: z.string().min(3).max(4)
})
const formSchema = z.discriminatedUnion('payment_method', [
z.object({
payment_method: z.literal('PIX')
}),
const formSchema = z.discriminatedUnion(
'payment_method',
[
z.object({
payment_method: z.literal('PIX')
}),
z.object({
payment_method: z.literal('BANK_SLIP')
}),
z.object({
payment_method: z.literal('BANK_SLIP')
}),
z.object({
payment_method: z.literal('MANUAL')
}),
z.object({
payment_method: z.literal('MANUAL')
}),
z.object({
payment_method: z.literal('CREDIT_CARD'),
credit_card: creditCard,
installments: z.coerce.number().int().min(1).max(12)
})
])
z.object({
payment_method: z.literal('CREDIT_CARD'),
credit_card: creditCard,
installments: z.coerce.number().int().min(1).max(12)
})
],
{ error: 'Escolha uma forma de pagamento' }
)
type Schema = z.input<typeof formSchema>
export type CreditCard = z.infer<typeof creditCard>
type PaymentProps = {
state: WizardState
onSubmit: (value: any) => void | Promise<void>
payment_method?: PaymentMethod
credit_card?: CreditCard
}
export function Payment({
onSubmit,
state,
payment_method: paymentMethodInit,
credit_card: creditCardInit = undefined
}: PaymentProps) {
export function Payment({}) {
const wizard = useWizard()
const { update, ...state } = useWizardStore()
const { control, handleSubmit } = useForm<Schema>({
defaultValues: {
payment_method: paymentMethodInit,
installments: state?.installments ?? 1,
credit_card: creditCardInit
payment_method: state.payment_method,
installments: state.installments,
credit_card: state.credit_card
},
resolver: zodResolver(formSchema)
})
@@ -114,13 +108,23 @@ export function Payment({
: 0
const total = subtotal > 0 ? subtotal + discount : 0
const onSubmit_ = async (data: Schema) => {
await onSubmit({ credit_card: undefined, ...data })
const onSubmit = async ({ payment_method, ...data }: Schema) => {
if (payment_method === 'CREDIT_CARD') {
// @ts-ignore
update({ payment_method, ...data })
} else {
update({
payment_method,
installments: undefined,
credit_card: undefined
})
}
wizard('review')
}
return (
<form onSubmit={handleSubmit(onSubmit_)} className="space-y-4">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Controller
name="payment_method"
control={control}
@@ -206,6 +210,7 @@ export function CreditCard({
{/* Credir card number */}
<Controller
control={control}
defaultValue=""
name="credit_card.number"
render={({ field: { onChange, ref, ...field }, fieldState }) => (
<Field data-invalid={fieldState.invalid}>

View File

@@ -37,11 +37,11 @@ import {
DialogTitle,
DialogTrigger
} from '@repo/ui/components/ui/dialog'
import { paymentMethods } from '@repo/ui/routes/orders/data'
import { useWizard } from '@/components/wizard'
import { type WizardState } from './route'
import { type WizardState } from './store'
import { applyDiscount } from './discount'
import { paymentMethods } from '@repo/ui/routes/orders/data'
import {
Field,
FieldDescription,
@@ -57,46 +57,28 @@ import {
InputGroupButton,
InputGroupInput
} from '@repo/ui/components/ui/input-group'
import { calcInterest } from './payment'
import { useWizardStore } from './store'
type ReviewProps = {
state: WizardState
onSubmit: (value: WizardState) => void | Promise<void>
}
export function Review({ state, onSubmit }: ReviewProps) {
export function Review({ onSubmit }: ReviewProps) {
const wizard = useWizard()
const { items, summary } = useWizardStore()
const { subtotal, discount, interest_amount, total } = summary()
const [loading, { set }] = useToggle()
const { coupon, items } = state || { items: [], coupon: {} }
const subtotal =
items?.reduce(
(acc, { course, quantity }) =>
acc +
(course?.unit_price || 0) *
(Number.isFinite(quantity) && quantity > 0 ? quantity : 1),
0
) || 0
const discount = coupon
? applyDiscount(subtotal, coupon.amount, coupon.type) * -1
: 0
const total = subtotal > 0 ? subtotal + discount : 0
const installments = state.installments
const interest_amount =
state.payment_method === 'CREDIT_CARD' &&
typeof installments === 'number' &&
installments > 1
? calcInterest(total, installments) - total
: 0
return (
<>
<Address total={total} {...state} />
<Summary />
<form
onSubmit={async (e) => {
e.preventDefault()
set(true)
await onSubmit(state)
// await onSubmit(state)
}}
className="space-y-4"
>
@@ -164,7 +146,7 @@ export function Review({ state, onSubmit }: ReviewProps) {
Total
</TableCell>
<TableCell>
<Currency>{total + interest_amount}</Currency>
<Currency>{total}</Currency>
</TableCell>
</TableRow>
</TableFooter>
@@ -193,11 +175,10 @@ export function Review({ state, onSubmit }: ReviewProps) {
)
}
export function Address({
total,
payment_method,
credit_card
}: WizardState & { total: number }) {
export function Summary() {
const { summary, credit_card, payment_method, installments } =
useWizardStore()
const { total } = summary()
const numberValidation = valid.number(credit_card?.number)
return (
@@ -229,7 +210,8 @@ export function Address({
{numberValidation.card?.niceType} (Crédito) ****{' '}
{credit_card.number.slice(-4)}
<br />
1x <Currency>{total}</Currency>
{installments}x{' '}
<Currency>{total / Number(installments)}</Currency>
</>
) : (
<>

View File

@@ -1,8 +1,8 @@
import type { Route } from './+types/route'
import { useState } from 'react'
import { useFetcher } from 'react-router'
import { Link } from 'react-router'
import { useLocalStorageState, useMount } from 'ahooks'
import { useMount } from 'ahooks'
import { BookSearchIcon, CircleCheckBigIcon, WalletIcon } from 'lucide-react'
import {
@@ -25,39 +25,17 @@ import { createSearch } from '@repo/util/meili'
import { cloudflareContext } from '@repo/auth/context'
import { Label } from '@repo/ui/components/ui/label'
import { Skeleton } from '@repo/ui/components/skeleton'
import type { PaymentMethod } from '@repo/ui/routes/orders/data'
import { Wizard, WizardStep } from '@/components/wizard'
import { Step, StepItem, StepSeparator } from '@/components/step'
import type { Course, Enrollment } from '../_.$orgid.enrollments.add/data'
import { Wizard, WizardStep } from '@/components/wizard'
import type { Course } from '../_.$orgid.enrollments.add/data'
import { Bulk } from './bulk'
import { Payment } from './payment'
import { Assigned } from './assigned'
import { Bulk, type Item } from './bulk'
import { Payment, type CreditCard } from './payment'
import { Review } from './review'
import type { Coupon } from './discount'
import { useFetcher } from 'react-router'
export type WizardState = {
index: number
kind: 'bulk' | 'assigned'
items: Item[]
enrollments: Enrollment[]
coupon?: Coupon
installments?: number
payment_method?: PaymentMethod
credit_card?: CreditCard
}
const emptyWizard: WizardState = {
index: 0,
kind: 'bulk',
items: [],
enrollments: [],
coupon: undefined,
payment_method: undefined,
installments: undefined,
credit_card: undefined
}
import { useWizardStore, type WizardState } from './store'
import { useState } from 'react'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Comprar matrículas' }]
@@ -87,20 +65,7 @@ export default function Route({
}: Route.ComponentProps) {
const fetcher = useFetcher()
const [mounted, setMounted] = useState(false)
const [state, setState] = useLocalStorageState<WizardState>('wizard_cart', {
defaultValue: emptyWizard
})
const index = state.index
const kind = state.kind
const props = {
...state,
courses,
onSubmit: async (data: any) =>
setState((prev) => ({
...(prev ?? emptyWizard),
...data
}))
}
const { index, kind, setIndex, setKind } = useWizardStore()
const onSubmit = async (data: WizardState) => {
await fetcher.submit(JSON.stringify(data), {
@@ -157,15 +122,7 @@ export default function Route({
</StepItem>
</Step>
<Wizard
index={index}
onChange={(nextIndex) =>
setState((prev) => ({
...(prev ?? emptyWizard),
index: nextIndex
}))
}
>
<Wizard index={index} onChange={setIndex}>
{/* Cart */}
<WizardStep name="cart">
<Label
@@ -187,40 +144,27 @@ export default function Route({
<Switch
checked={kind === 'assigned'}
onCheckedChange={(checked) =>
setState((prev) => ({
...(prev ?? emptyWizard),
kind: checked ? 'assigned' : 'bulk'
}))
setKind(checked ? 'assigned' : 'bulk')
}
className="cursor-pointer"
/>
</Label>
{kind == 'assigned' ? (
<Assigned {...props} />
{kind === 'assigned' ? (
<Assigned courses={courses} />
) : (
<Bulk {...props} />
<Bulk courses={courses} />
)}
</WizardStep>
{/* Payment */}
<WizardStep name="payment">
<Payment
state={state}
payment_method={state.payment_method}
credit_card={state.credit_card}
onSubmit={(data: any) => {
setState((prev) => ({
...(prev ?? emptyWizard),
...data
}))
}}
/>
<Payment />
</WizardStep>
{/* Review */}
<WizardStep name="review">
<Review state={state} onSubmit={onSubmit} />
<Review onSubmit={onSubmit} />
</WizardStep>
</Wizard>
</CardContent>

View File

@@ -0,0 +1,136 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { applyDiscount, type Coupon } from './discount'
import { calcInterest, type CreditCard } from './payment'
import type { PaymentMethod } from '@repo/ui/routes/orders/data'
import type { Enrollment } from '../_.$orgid.enrollments.add/data'
import type { Item } from './bulk'
export type WizardState = {
index: number
kind: 'bulk' | 'assigned'
items: Item[]
enrollments: Enrollment[]
coupon?: Coupon
installments?: number
payment_method?: PaymentMethod
credit_card?: CreditCard
}
type Summary = {
subtotal: number
discount: number
interest_amount: number
total: number
}
export type WizardStore = WizardState & {
setIndex: (index: number) => void
setKind: (kind: 'bulk' | 'assigned') => void
update: (data: Partial<WizardState>) => void
reset: () => void
summary: () => Summary
}
const emptyWizard: WizardState = {
index: 0,
kind: 'bulk',
items: [],
enrollments: []
}
export const useWizardStore = create<WizardStore>()(
persist(
(set, get) => ({
...emptyWizard,
setIndex: (index) => set({ index }),
setKind: (kind) => set({ kind }),
summary: () => {
const { items, coupon, credit_card, installments } = get()
const subtotal = items.reduce(
(acc, { course, quantity }) =>
acc +
(course?.unit_price || 0) *
(Number.isFinite(quantity) && quantity > 0 ? quantity : 1),
0
)
const discount = coupon
? applyDiscount(subtotal, coupon.amount, coupon.type) * -1
: 0
const total = subtotal > 0 ? subtotal + discount : 0
const interest_amount =
credit_card && typeof installments === 'number' && installments > 1
? calcInterest(total, installments) - total
: 0
return {
subtotal,
discount,
interest_amount,
total: total + interest_amount
}
},
update: (data) =>
set((state) => {
if (data.enrollments) {
const items = Object.values(
data.enrollments.reduce<Record<string, Item>>(
(acc, { course }) => {
const { id } = course
acc[id] ??= { course, quantity: 0 }
acc[id].quantity++
return acc
},
{}
)
)
return {
...state,
...data,
items
}
}
if (data.items) {
const items = Object.values(
data.items.reduce<Record<string, Item>>((acc, item) => {
const id = item.course.id
if (!acc[id]) {
acc[id] = { ...item }
} else {
acc[id].quantity += item.quantity
}
return acc
}, {})
)
return {
...state,
...data,
items,
enrollments: []
}
}
return {
...state,
...data
}
}),
reset: () => set({ ...emptyWizard })
}),
{
name: 'wizard_cart'
}
)
)