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

@@ -50,6 +50,7 @@ app.include_router(users.password, prefix='/users')
app.include_router(orders.router, prefix='/orders') app.include_router(orders.router, prefix='/orders')
app.include_router(orders.checkout, prefix='/orders') app.include_router(orders.checkout, prefix='/orders')
app.include_router(orgs.add, prefix='/orgs') app.include_router(orgs.add, prefix='/orgs')
app.include_router(orgs.address, prefix='/orgs')
app.include_router(orgs.admins, prefix='/orgs') app.include_router(orgs.admins, prefix='/orgs')
app.include_router(orgs.billing, prefix='/orgs') app.include_router(orgs.billing, prefix='/orgs')
app.include_router(orgs.custom_pricing, prefix='/orgs') app.include_router(orgs.custom_pricing, prefix='/orgs')

View File

@@ -1,4 +1,5 @@
from .add import router as add from .add import router as add
from .address import router as address
from .admins import router as admins from .admins import router as admins
from .billing import router as billing from .billing import router as billing
from .custom_pricing import router as custom_pricing from .custom_pricing import router as custom_pricing
@@ -9,6 +10,7 @@ from .users.batch_jobs import router as batch_jobs
__all__ = [ __all__ = [
'add', 'add',
'address',
'admins', 'admins',
'billing', 'billing',
'custom_pricing', 'custom_pricing',

View File

@@ -0,0 +1,22 @@
from http import HTTPMethod, HTTPStatus
from layercake.dynamodb import DynamoDBPersistenceLayer
from ...conftest import HttpApiProxy, LambdaContext
def test_address(
app,
seeds,
http_api_proxy: HttpApiProxy,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
r = app.lambda_handler(
http_api_proxy(
raw_path='/orgs/cJtK9SsnJhKPyxESe7g3DG/address',
method=HTTPMethod.GET,
),
lambda_context,
)
assert r['statusCode'] == HTTPStatus.OK

View File

@@ -20,4 +20,4 @@ export const labels: Record<string, string> = {
CLOSED: 'Fechado' CLOSED: 'Fechado'
} }
export const tz = 'America/Sao_Paulo' export const tz = 'local'

View File

@@ -203,7 +203,7 @@ export default function Route({
<DateTime> <DateTime>
{LuxonDateTime.fromISO( {LuxonDateTime.fromISO(
output.scheduled_for, output.scheduled_for,
{ zone: 'America/Sao_Paulo' } { zone: 'local' }
).toJSDate()} ).toJSDate()}
{} {}
</DateTime> </DateTime>
@@ -239,7 +239,11 @@ export default function Route({
> >
<li> <li>
<CalendarIcon className="size-3.5" /> <CalendarIcon className="size-3.5" />
{datetime.format(new Date(sk))} <DateTime
options={{ hour: '2-digit', minute: '2-digit' }}
>
{sk}
</DateTime>
</li> </li>
<li> <li>
<UserIcon className="size-3.5" /> {created_by.name} <UserIcon className="size-3.5" /> {created_by.name}
@@ -256,14 +260,6 @@ export default function Route({
) )
} }
const datetime = new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
function NotFound() { function NotFound() {
return ( return (
<> <>

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,11 +37,11 @@ import {
DialogTitle, DialogTitle,
DialogTrigger DialogTrigger
} from '@repo/ui/components/ui/dialog' } from '@repo/ui/components/ui/dialog'
import { paymentMethods } from '@repo/ui/routes/orders/data'
import { useWizard } from '@/components/wizard' import { useWizard } from '@/components/wizard'
import { type WizardState } from './route' import { type WizardState } from './store'
import { applyDiscount } from './discount' import { applyDiscount } from './discount'
import { paymentMethods } from '@repo/ui/routes/orders/data'
import { import {
Field, Field,
FieldDescription, FieldDescription,
@@ -57,46 +57,28 @@ import {
InputGroupButton, InputGroupButton,
InputGroupInput InputGroupInput
} from '@repo/ui/components/ui/input-group' } from '@repo/ui/components/ui/input-group'
import { calcInterest } from './payment'
import { useWizardStore } from './store'
type ReviewProps = { type ReviewProps = {
state: WizardState
onSubmit: (value: WizardState) => void | Promise<void> onSubmit: (value: WizardState) => void | Promise<void>
} }
export function Review({ state, onSubmit }: ReviewProps) { export function Review({ onSubmit }: ReviewProps) {
const wizard = useWizard() const wizard = useWizard()
const { items, summary } = useWizardStore()
const { subtotal, discount, interest_amount, total } = summary()
const [loading, { set }] = useToggle() 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 ( return (
<> <>
<Address total={total} {...state} /> <Summary />
<form <form
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault() e.preventDefault()
set(true) set(true)
await onSubmit(state) // await onSubmit(state)
}} }}
className="space-y-4" className="space-y-4"
> >
@@ -164,7 +146,7 @@ export function Review({ state, onSubmit }: ReviewProps) {
Total Total
</TableCell> </TableCell>
<TableCell> <TableCell>
<Currency>{total + interest_amount}</Currency> <Currency>{total}</Currency>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableFooter> </TableFooter>
@@ -193,11 +175,10 @@ export function Review({ state, onSubmit }: ReviewProps) {
) )
} }
export function Address({ export function Summary() {
total, const { summary, credit_card, payment_method, installments } =
payment_method, useWizardStore()
credit_card const { total } = summary()
}: WizardState & { total: number }) {
const numberValidation = valid.number(credit_card?.number) const numberValidation = valid.number(credit_card?.number)
return ( return (
@@ -229,7 +210,8 @@ export function Address({
{numberValidation.card?.niceType} (Crédito) ****{' '} {numberValidation.card?.niceType} (Crédito) ****{' '}
{credit_card.number.slice(-4)} {credit_card.number.slice(-4)}
<br /> <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 type { Route } from './+types/route'
import { useState } from 'react' import { useFetcher } from 'react-router'
import { Link } 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 { BookSearchIcon, CircleCheckBigIcon, WalletIcon } from 'lucide-react'
import { import {
@@ -25,39 +25,17 @@ import { createSearch } from '@repo/util/meili'
import { cloudflareContext } from '@repo/auth/context' import { cloudflareContext } from '@repo/auth/context'
import { Label } from '@repo/ui/components/ui/label' import { Label } from '@repo/ui/components/ui/label'
import { Skeleton } from '@repo/ui/components/skeleton' 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 { 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 { Assigned } from './assigned'
import { Bulk, type Item } from './bulk'
import { Payment, type CreditCard } from './payment'
import { Review } from './review' import { Review } from './review'
import type { Coupon } from './discount'
import { useFetcher } from 'react-router'
export type WizardState = { import { useWizardStore, type WizardState } from './store'
index: number import { useState } from 'react'
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
}
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: 'Comprar matrículas' }] return [{ title: 'Comprar matrículas' }]
@@ -87,20 +65,7 @@ export default function Route({
}: Route.ComponentProps) { }: Route.ComponentProps) {
const fetcher = useFetcher() const fetcher = useFetcher()
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const [state, setState] = useLocalStorageState<WizardState>('wizard_cart', { const { index, kind, setIndex, setKind } = useWizardStore()
defaultValue: emptyWizard
})
const index = state.index
const kind = state.kind
const props = {
...state,
courses,
onSubmit: async (data: any) =>
setState((prev) => ({
...(prev ?? emptyWizard),
...data
}))
}
const onSubmit = async (data: WizardState) => { const onSubmit = async (data: WizardState) => {
await fetcher.submit(JSON.stringify(data), { await fetcher.submit(JSON.stringify(data), {
@@ -157,15 +122,7 @@ export default function Route({
</StepItem> </StepItem>
</Step> </Step>
<Wizard <Wizard index={index} onChange={setIndex}>
index={index}
onChange={(nextIndex) =>
setState((prev) => ({
...(prev ?? emptyWizard),
index: nextIndex
}))
}
>
{/* Cart */} {/* Cart */}
<WizardStep name="cart"> <WizardStep name="cart">
<Label <Label
@@ -187,40 +144,27 @@ export default function Route({
<Switch <Switch
checked={kind === 'assigned'} checked={kind === 'assigned'}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setState((prev) => ({ setKind(checked ? 'assigned' : 'bulk')
...(prev ?? emptyWizard),
kind: checked ? 'assigned' : 'bulk'
}))
} }
className="cursor-pointer" className="cursor-pointer"
/> />
</Label> </Label>
{kind == 'assigned' ? ( {kind === 'assigned' ? (
<Assigned {...props} /> <Assigned courses={courses} />
) : ( ) : (
<Bulk {...props} /> <Bulk courses={courses} />
)} )}
</WizardStep> </WizardStep>
{/* Payment */} {/* Payment */}
<WizardStep name="payment"> <WizardStep name="payment">
<Payment <Payment />
state={state}
payment_method={state.payment_method}
credit_card={state.credit_card}
onSubmit={(data: any) => {
setState((prev) => ({
...(prev ?? emptyWizard),
...data
}))
}}
/>
</WizardStep> </WizardStep>
{/* Review */} {/* Review */}
<WizardStep name="review"> <WizardStep name="review">
<Review state={state} onSubmit={onSubmit} /> <Review onSubmit={onSubmit} />
</WizardStep> </WizardStep>
</Wizard> </Wizard>
</CardContent> </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'
}
)
)

View File

@@ -16,7 +16,7 @@ import {
UserIcon UserIcon
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { DateTime } from 'luxon' import { DateTime as LuxonDateTime } from 'luxon'
import { Fragment, Suspense } from 'react' import { Fragment, Suspense } from 'react'
import { Await } from 'react-router' import { Await } from 'react-router'
@@ -67,6 +67,7 @@ import {
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
import { useParams } from 'react-router' import { useParams } from 'react-router'
import { useRevalidator } from 'react-router' import { useRevalidator } from 'react-router'
import { DateTime } from '@repo/ui/components/datetime'
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
@@ -239,7 +240,7 @@ function Timeline({
{events.map(([run_at, items], index) => ( {events.map(([run_at, items], index) => (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-2.5" key={index}> <div className="grid grid-cols-1 lg:grid-cols-5 gap-2.5" key={index}>
<div> <div>
{DateTime.fromISO(run_at) {LuxonDateTime.fromISO(run_at)
.setLocale('pt-BR') .setLocale('pt-BR')
.toFormat('cccc, dd LLL yyyy')} .toFormat('cccc, dd LLL yyyy')}
</div> </div>
@@ -276,7 +277,13 @@ function Scheduled({ items = [] }) {
<ul className="lg:flex gap-2.5 text-muted-foreground text-sm *:flex *:gap-1 *:items-center"> <ul className="lg:flex gap-2.5 text-muted-foreground text-sm *:flex *:gap-1 *:items-center">
<li> <li>
<CalendarIcon className="size-3.5" /> <CalendarIcon className="size-3.5" />
<span>{datetime.format(new Date(scheduled_at))}</span> <span>
<DateTime
options={{ hour: '2-digit', minute: '2-digit' }}
>
{scheduled_at}
</DateTime>
</span>
</li> </li>
<li> <li>
<UserIcon className="size-3.5" /> <UserIcon className="size-3.5" />
@@ -345,7 +352,13 @@ function Executed({ items = [] }) {
<ul className="lg:flex gap-2.5 text-muted-foreground text-sm *:flex *:gap-1 *:items-center"> <ul className="lg:flex gap-2.5 text-muted-foreground text-sm *:flex *:gap-1 *:items-center">
<li> <li>
<ClockCheckIcon className="size-3.5" /> <ClockCheckIcon className="size-3.5" />
<span>{datetime.format(new Date(created_at))}</span> <span>
<DateTime
options={{ hour: '2-digit', minute: '2-digit' }}
>
{created_at}
</DateTime>
</span>
</li> </li>
</ul> </ul>
</div> </div>
@@ -384,7 +397,13 @@ function Failed({ items = [] }) {
<ul className="lg:flex gap-2.5 text-muted-foreground text-sm *:flex *:gap-1 *:items-center"> <ul className="lg:flex gap-2.5 text-muted-foreground text-sm *:flex *:gap-1 *:items-center">
<li> <li>
<ClockAlertIcon className="size-3.5" /> <ClockAlertIcon className="size-3.5" />
<span>{datetime.format(new Date(created_at))}</span> <span>
<DateTime
options={{ hour: '2-digit', minute: '2-digit' }}
>
{created_at}
</DateTime>
</span>
</li> </li>
{cause?.type === 'DeduplicationConflictError' ? ( {cause?.type === 'DeduplicationConflictError' ? (
@@ -535,11 +554,3 @@ function grouping(items) {
) )
return newItems.sort((x, y) => x[0].localeCompare(y[0])) return newItems.sort((x, y) => x[0].localeCompare(y[0]))
} }
const datetime = new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})

View File

@@ -31,7 +31,8 @@
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"react-router": "^7.10.1", "react-router": "^7.10.1",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"zod": "^4.1.13" "zod": "^4.1.13",
"zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "^1.17.0", "@cloudflare/vite-plugin": "^1.17.0",

32
package-lock.json generated
View File

@@ -44,7 +44,8 @@
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"react-router": "^7.10.1", "react-router": "^7.10.1",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"zod": "^4.1.13" "zod": "^4.1.13",
"zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "^1.17.0", "@cloudflare/vite-plugin": "^1.17.0",
@@ -7488,6 +7489,35 @@
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
}, },
"node_modules/zustand": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
},
"packages/auth": { "packages/auth": {
"name": "@repo/auth", "name": "@repo/auth",
"version": "0.0.0", "version": "0.0.0",