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 (
+
+
+
+ )
+}
+
+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 }