From 0400dc48502dcf0d262b692a3ee4b5203a65ce41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Thu, 25 Dec 2025 18:11:49 -0300 Subject: [PATCH] add steps --- .../app/routes/orders/checkout.py | 7 ++ .../app/components/step.tsx | 80 ++++++++++++++++++ .../app/components/wizard.tsx | 76 +++++++++++++++++ .../_.$orgid.enrollments._index/route.tsx | 12 +-- .../_.$orgid.enrollments.buy/assigned.tsx | 16 ++-- .../routes/_.$orgid.enrollments.buy/bulk.tsx | 26 +++--- .../_.$orgid.enrollments.buy/payment.tsx | 42 ++++++++++ .../routes/_.$orgid.enrollments.buy/route.tsx | 84 ++++++++++++------- package-lock.json | 33 ++++++++ packages/ui/package.json | 1 + packages/ui/src/components/ui/radio-group.tsx | 45 ++++++++++ 11 files changed, 366 insertions(+), 56 deletions(-) create mode 100644 apps/admin.saladeaula.digital/app/components/step.tsx create mode 100644 apps/admin.saladeaula.digital/app/components/wizard.tsx create mode 100644 apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/payment.tsx create mode 100644 packages/ui/src/components/ui/radio-group.tsx diff --git a/api.saladeaula.digital/app/routes/orders/checkout.py b/api.saladeaula.digital/app/routes/orders/checkout.py index b3fafff..959f2fd 100644 --- a/api.saladeaula.digital/app/routes/orders/checkout.py +++ b/api.saladeaula.digital/app/routes/orders/checkout.py @@ -52,6 +52,12 @@ class Item(BaseModel): quantity: int = 1 +class Coupon(BaseModel): + code: str + type: Literal['PERCENT', 'FIXED'] + amount: Decimal + + class Checkout(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) @@ -60,6 +66,7 @@ class Checkout(BaseModel): address: Address payment_method: Literal['PIX', 'CREDIT_CARD', 'BANK_SLIP', 'MANUAL'] items: tuple[Item, ...] + coupon: Coupon | None = None user: User | None = None org_id: UUID4 | str | None = None user_id: UUID4 | str | None = None diff --git a/apps/admin.saladeaula.digital/app/components/step.tsx b/apps/admin.saladeaula.digital/app/components/step.tsx new file mode 100644 index 0000000..4a5c9c9 --- /dev/null +++ b/apps/admin.saladeaula.digital/app/components/step.tsx @@ -0,0 +1,80 @@ +import { type ReactNode, createContext, useContext } from 'react' +import { type LucideIcon } from 'lucide-react' + +import { cn } from '@repo/ui/lib/utils' + +type StepContextValue = { + activeIndex: number +} + +const StepContext = createContext(null) + +function useStep() { + const ctx = useContext(StepContext) + if (!ctx) { + throw new Error('StepItem must be used inside ') + } + return ctx +} + +export function Step({ + children, + className, + activeIndex +}: { + children: ReactNode + className?: string + activeIndex: number +}) { + return ( + +
    + {children} +
+
+ ) +} + +export function StepSeparator() { + return ( +
+ ) +} + +export function StepItem({ + children, + icon: Icon, + index +}: { + children: ReactNode + icon: LucideIcon + index: number +}) { + const { activeIndex } = useStep() + const active = index === activeIndex + + return ( +
  • +
    + +
    + +
    {children}
    +
  • + ) +} diff --git a/apps/admin.saladeaula.digital/app/components/wizard.tsx b/apps/admin.saladeaula.digital/app/components/wizard.tsx new file mode 100644 index 0000000..cf50565 --- /dev/null +++ b/apps/admin.saladeaula.digital/app/components/wizard.tsx @@ -0,0 +1,76 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, + type ReactElement +} from 'react' + +type WizardContextProps = (name: string) => void + +const WizardContext = createContext(null) + +export function useWizard(): WizardContextProps { + const ctx = useContext(WizardContext) + + if (!ctx) { + throw new Error('useWizard must be used within ') + } + + return ctx +} + +type WizardProps = { + children: ReactNode + index?: number + onChange?: (index: number) => void +} + +export function Wizard({ + children, + index: initIndex = 0, + onChange +}: WizardProps) { + const [index, setIndex] = useState(initIndex) + + const components = useMemo( + () => React.Children.toArray(children) as ReactElement[], + [children] + ) + + const steps = useMemo( + () => components.map((child) => child.props.name), + [components] + ) + + const child = components[index] + + const onChange_ = useCallback( + (name) => { + const nextIndex = steps.findIndex((n) => n === name) + + if (nextIndex >= 0) { + setIndex(nextIndex) + onChange?.(nextIndex) + } + }, + [steps] + ) + + return ( + + {React.isValidElement(child) ? React.cloneElement(child) : child} + + ) +} + +export type WizardStepProps = { + name: string + children: React.ReactNode +} + +export function WizardStep({ children }: WizardStepProps) { + return <>{children} +} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx index 05aba4e..3c3c0de 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx @@ -58,11 +58,13 @@ export async function loader({ params, context, request }: Route.LoaderArgs) { }) return { - data: enrollments + enrollments } } -export default function Route({ loaderData: { data } }: Route.ComponentProps) { +export default function Route({ + loaderData: { enrollments } +}: Route.ComponentProps) { const { orgid } = useParams() const [searchParams, setSearchParams] = useSearchParams() const [selectedRows, setSelectedRows] = useState([]) @@ -80,7 +82,7 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {

    - + {({ hits, page = 1, hitsPerPage, totalHits }) => ( +type Schema = z.infer type AssignedProps = { onSubmit: (value: any) => void | Promise @@ -60,9 +61,10 @@ type AssignedProps = { } export function Assigned({ courses, onSubmit }: AssignedProps) { + const wizard = useWizard() const { orgid } = useParams() const form = useForm({ - resolver: zodResolver(formSchema_), + resolver: zodResolver(formSchemaAssigned), defaultValues: { enrollments: [emptyRow] } }) const { formState, control, handleSubmit, setValue } = form @@ -79,10 +81,6 @@ export function Assigned({ courses, onSubmit }: AssignedProps) { (acc, { course }) => acc + (course?.unit_price || 0), 0 ) - const discount = coupon - ? applyDiscount(subtotal, coupon.amount, coupon.type) * -1 - : 0 - const total = subtotal > 0 ? subtotal + discount : 0 const onSearch = async (search: string) => { const params = new URLSearchParams({ q: search }) @@ -93,6 +91,7 @@ export function Assigned({ courses, onSubmit }: AssignedProps) { const onSubmit_ = async (data: Schema) => { await onSubmit(data) + wizard('payment') } return ( @@ -247,7 +246,8 @@ export function Assigned({ courses, onSubmit }: AssignedProps) { - + {/* Summary */} + diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/bulk.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/bulk.tsx index 60a593b..8d54fe5 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/bulk.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/bulk.tsx @@ -29,6 +29,7 @@ import { CoursePicker } from '../_.$orgid.enrollments.add/course-picker' import { MAX_ITEMS, type Course } from '../_.$orgid.enrollments.add/data' import { Discount, applyDiscount } from './discount' import { currency } from './utils' +import { useWizard } from '@/components/wizard' const emptyRow = { course: undefined @@ -68,6 +69,7 @@ const formSchema = z.object({ type Schema = z.infer export function Bulk({ courses, onSubmit }: BulkProps) { + const wizard = useWizard() const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { items: [emptyRow] } @@ -97,13 +99,10 @@ export function Bulk({ courses, onSubmit }: BulkProps) { (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 onSubmit_ = async (data: Schema) => { await onSubmit(data) + wizard('payment') } return ( @@ -284,7 +283,7 @@ export function Bulk({ courses, onSubmit }: BulkProps) { - + @@ -306,23 +305,20 @@ export function Bulk({ courses, onSubmit }: BulkProps) { type SummaryProps = { subtotal: number - total: number - discount: number coupon?: { code: string type: 'FIXED' | 'PERCENT' amount: number } - setValue: UseFormSetValue + setValue: UseFormSetValue } -export function Summary({ - subtotal, - total, - discount, - coupon, - setValue -}: SummaryProps) { +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 ( <> {/* Subtotal */} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/payment.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/payment.tsx new file mode 100644 index 0000000..70a620e --- /dev/null +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/payment.tsx @@ -0,0 +1,42 @@ +import { Button } from '@repo/ui/components/ui/button' +import { Label } from '@repo/ui/components/ui/label' +import { RadioGroup, RadioGroupItem } from '@repo/ui/components/ui/radio-group' +import { Separator } from '@repo/ui/components/ui/separator' + +export function Payment() { + return ( + <> + + + + + + + + +
    + +
    + + ) +} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/route.tsx index 0b532ef..8595df4 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/route.tsx @@ -2,6 +2,7 @@ import type { Route } from './+types/route' import { Link } from 'react-router' import { useToggle } from 'ahooks' +import { BookSearchIcon, CircleCheckBigIcon, WalletIcon } from 'lucide-react' import { Card, @@ -23,9 +24,13 @@ import { createSearch } from '@repo/util/meili' import { cloudflareContext } from '@repo/auth/context' import { Label } from '@repo/ui/components/ui/label' +import { Wizard, WizardStep } from '@/components/wizard' +import { Step, StepItem, StepSeparator } from '@/components/step' +import type { Course } from '../_.$orgid.enrollments.add/data' import { Assigned } from './assigned' import { Bulk } from './bulk' -import type { Course } from '../_.$orgid.enrollments.add/data' +import { Payment } from './payment' +import { useState } from 'react' export function meta({}: Route.MetaArgs) { return [{ title: 'Comprar matrículas' }] @@ -52,10 +57,11 @@ export async function action({ request }: Route.ActionArgs) { export default function Route({ loaderData: { courses } }: Route.ComponentProps) { + const [index, setIndex] = useState(0) const [state, { toggle }] = useToggle('bulk', 'assigned') const onSubmit = async (data: any) => { - await new Promise((r) => setTimeout(r, 2000)) + // await new Promise((r) => setTimeout(r, 2000)) console.log(data) } @@ -87,33 +93,55 @@ export default function Route({ - + + + Escolher cursos + + + + Pagamento + + + + Revisão & confirmação + + - {state == 'assigned' ? ( - - ) : ( - - )} + + + + + {state == 'assigned' ? ( + + ) : ( + + )} + + + + + + diff --git a/package-lock.json b/package-lock.json index ca1cfcc..5a64681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3315,6 +3315,38 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -7522,6 +7554,7 @@ "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/packages/ui/package.json b/packages/ui/package.json index 05ecab5..978c9b0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/packages/ui/src/components/ui/radio-group.tsx b/packages/ui/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..5e6778c --- /dev/null +++ b/packages/ui/src/components/ui/radio-group.tsx @@ -0,0 +1,45 @@ +"use client" + +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem }