From 2459bafc5db40bf500682dd4677575480b7893bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Wed, 24 Dec 2025 01:40:07 -0300 Subject: [PATCH] add discount --- api.saladeaula.digital/app/app.py | 3 +- .../app/routes/coupons/__init__.py | 25 ++++ api.saladeaula.digital/tests/conftest.py | 1 + .../tests/routes/test_coupons.py | 19 +++ api.saladeaula.digital/tests/seeds.jsonl | 5 + .../app/routes/_.$orgid._index/route.tsx | 3 +- .../routes/_.$orgid.billing._index/route.tsx | 3 + .../app/routes/_.$orgid.checkout/route.tsx | 85 ----------- .../_.$orgid.enrollments._index/route.tsx | 19 ++- .../routes/_.$orgid.enrollments.add/route.tsx | 3 +- .../assigned.tsx | 19 ++- .../bulk.tsx | 96 ++++++++++--- .../_.$orgid.enrollments.buy/discount.tsx | 135 ++++++++++++++++++ .../routes/_.$orgid.enrollments.buy/route.tsx | 122 ++++++++++++++++ .../app/routes/_.$orgid/route.tsx | 1 + .../components/data-table/view-options.tsx | 16 ++- 16 files changed, 423 insertions(+), 132 deletions(-) create mode 100644 api.saladeaula.digital/app/routes/coupons/__init__.py create mode 100644 api.saladeaula.digital/tests/routes/test_coupons.py delete mode 100644 apps/admin.saladeaula.digital/app/routes/_.$orgid.checkout/route.tsx rename apps/admin.saladeaula.digital/app/routes/{_.$orgid.checkout => _.$orgid.enrollments.buy}/assigned.tsx (95%) rename apps/admin.saladeaula.digital/app/routes/{_.$orgid.checkout => _.$orgid.enrollments.buy}/bulk.tsx (80%) create mode 100644 apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/discount.tsx create mode 100644 apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/route.tsx diff --git a/api.saladeaula.digital/app/app.py b/api.saladeaula.digital/app/app.py index ac06575..28ad610 100644 --- a/api.saladeaula.digital/app/app.py +++ b/api.saladeaula.digital/app/app.py @@ -15,7 +15,7 @@ from aws_lambda_powertools.utilities.typing import LambdaContext from api_gateway import JSONResponse from json_encoder import JSONEncoder from middlewares.authentication_middleware import AuthenticationMiddleware -from routes import courses, enrollments, orders, orgs, users +from routes import coupons, courses, enrollments, orders, orgs, users logger = Logger(__name__) tracer = Tracer() @@ -35,6 +35,7 @@ app = APIGatewayHttpResolver( ) app.use(middlewares=[AuthenticationMiddleware()]) app.enable_swagger(path='/swagger') +app.include_router(coupons.router, prefix='/coupons') app.include_router(courses.router, prefix='/courses') app.include_router(enrollments.router, prefix='/enrollments') app.include_router(enrollments.cancel, prefix='/enrollments') diff --git a/api.saladeaula.digital/app/routes/coupons/__init__.py b/api.saladeaula.digital/app/routes/coupons/__init__.py new file mode 100644 index 0000000..355c614 --- /dev/null +++ b/api.saladeaula.digital/app/routes/coupons/__init__.py @@ -0,0 +1,25 @@ +from typing import Annotated + +from aws_lambda_powertools.event_handler.api_gateway import Router +from aws_lambda_powertools.event_handler.exceptions import ( + NotFoundError, +) +from aws_lambda_powertools.event_handler.openapi.params import Path +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair + +from boto3clients import dynamodb_client +from config import ORDER_TABLE + +router = Router() +dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) + + +class CouponNotFoundError(NotFoundError): ... + + +@router.get('/') +def coupon(coupon: Annotated[str, Path(min_length=3)]): + return dyn.collection.get_item( + KeyPair('COUPON', coupon.upper()), + exc_cls=CouponNotFoundError, + ) diff --git a/api.saladeaula.digital/tests/conftest.py b/api.saladeaula.digital/tests/conftest.py index 4306adb..bcc87a3 100644 --- a/api.saladeaula.digital/tests/conftest.py +++ b/api.saladeaula.digital/tests/conftest.py @@ -17,6 +17,7 @@ SK = 'sk' def pytest_configure(): os.environ['TZ'] = 'America/Sao_Paulo' os.environ['COURSE_TABLE'] = PYTEST_TABLE_NAME + os.environ['ORDER_TABLE'] = PYTEST_TABLE_NAME os.environ['USER_TABLE'] = PYTEST_TABLE_NAME os.environ['ENROLLMENT_TABLE'] = PYTEST_TABLE_NAME os.environ['BUCKET_NAME'] = 'saladeaula.digital' diff --git a/api.saladeaula.digital/tests/routes/test_coupons.py b/api.saladeaula.digital/tests/routes/test_coupons.py new file mode 100644 index 0000000..308d139 --- /dev/null +++ b/api.saladeaula.digital/tests/routes/test_coupons.py @@ -0,0 +1,19 @@ +from http import HTTPMethod, HTTPStatus + +from ..conftest import HttpApiProxy, LambdaContext + + +def test_get_coupon( + app, + seeds, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/coupons/10off', + method=HTTPMethod.GET, + ), + lambda_context, + ) + assert r['statusCode'] == HTTPStatus.OK diff --git a/api.saladeaula.digital/tests/seeds.jsonl b/api.saladeaula.digital/tests/seeds.jsonl index 1e095fa..29327e1 100644 --- a/api.saladeaula.digital/tests/seeds.jsonl +++ b/api.saladeaula.digital/tests/seeds.jsonl @@ -39,3 +39,8 @@ // Course scormset {"id": "c27d1b4f-575c-4b6b-82a1-9b91ff369e0b", "sk": "SCORMSET#76c75561-d972-43ef-9818-497d8fc6edbe", "packages": [{"version": "1.2", "scormdriver": "s3://saladeaula.digital/scorm/nr-33-espacos-confinados-conteudo-de-demonstracao-scorm12/scormdriver/indexAPI.html"}]} + + +// Discounts +{"id": "COUPON", "sk": "PRIMEIRACOMPRA", "discount_amount": 15, "discount_type": "FIXED", "created_at": "2025-12-24T00:05:27-03:00"} +{"id": "COUPON", "sk": "10OFF", "discount_amount": 10, "discount_type": "PERCENT", "created_at": "2025-12-24T00:05:27-03:00"} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid._index/route.tsx index b83c4c5..50a22fa 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid._index/route.tsx @@ -3,6 +3,5 @@ import type { Route } from './+types/route' import { redirect } from 'react-router' export async function loader({ params }: Route.LoaderArgs) { - const { orgid } = params - throw redirect(`/${orgid}/main`) + throw redirect(`/${params.orgid}/main`) } diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/route.tsx index c253e8d..f296d7b 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/route.tsx @@ -230,6 +230,7 @@ function List({ items, search }) { Valor unit. + {charges?.map( ( @@ -306,6 +307,7 @@ function List({ items, search }) { {subtotal} + Descontos @@ -314,6 +316,7 @@ function List({ items, search }) { {discounts} + Total diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.checkout/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.checkout/route.tsx deleted file mode 100644 index 51a53cc..0000000 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.checkout/route.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { Route } from './+types/route' - -import { useToggle } from 'ahooks' - -import { - Card, - CardContent, - CardHeader, - CardDescription, - CardTitle -} from '@repo/ui/components/ui/card' -import { Switch } from '@repo/ui/components/ui/switch' -import { createSearch } from '@repo/util/meili' -import { cloudflareContext } from '@repo/auth/context' -import { Label } from '@repo/ui/components/ui/label' - -import { Assigned } from './assigned' -import { Bulk } from './bulk' -import type { Course } from '../_.$orgid.enrollments.add/data' - -export function meta({}: Route.MetaArgs) { - return [{ title: 'Comprar matrículas' }] -} - -export async function loader({ params, context, request }: Route.LoaderArgs) { - const cloudflare = context.get(cloudflareContext) - const courses = createSearch({ - index: 'saladeaula_courses', - sort: ['created_at:desc'], - filter: 'unlisted NOT EXISTS', - hitsPerPage: 100, - env: cloudflare.env - }) - - return { courses } -} - -export default function Route({ - loaderData: { courses } -}: Route.ComponentProps) { - const [state, { toggle }] = useToggle('bulk', 'assigned') - - return ( -
- - - Comprar matrículas - - Siga os passos abaixo para comprar novas matrículas. - - - - - - - {state == 'assigned' ? ( - - ) : ( - - )} - - -
- ) -} 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 a8a60e5..05aba4e 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 @@ -62,12 +62,6 @@ export async function loader({ params, context, request }: Route.LoaderArgs) { } } -const formatted = new Intl.DateTimeFormat('en-CA', { - year: 'numeric', - month: '2-digit', - day: '2-digit' -}) - export default function Route({ loaderData: { data } }: Route.ComponentProps) { const { orgid } = useParams() const [searchParams, setSearchParams] = useSearchParams() @@ -176,6 +170,12 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) { }) )} onChange={(props) => { + const dt = new Intl.DateTimeFormat('en-CA', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }) + setSearchParams((searchParams) => { if (!props) { searchParams.delete('from') @@ -187,12 +187,9 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) { searchParams.set( 'from', - `${rangeField}:${formatted.format(dateRange?.from)}` - ) - searchParams.set( - 'to', - formatted.format(dateRange?.to) + `${rangeField}:${dt.format(dateRange?.from)}` ) + searchParams.set('to', dt.format(dateRange?.to)) return searchParams }) diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx index 6514ec0..bec0aca 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx @@ -65,7 +65,6 @@ import { MAX_ITEMS, formSchema, type Schema, - type Course, type User, type Enrolled } from './data' @@ -405,7 +404,7 @@ function DuplicateRowMultipleTimes({ - +
{ e.stopPropagation() diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.checkout/assigned.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/assigned.tsx similarity index 95% rename from apps/admin.saladeaula.digital/app/routes/_.$orgid.checkout/assigned.tsx rename to apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/assigned.tsx index 10a2fb7..d88d812 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.checkout/assigned.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/assigned.tsx @@ -13,6 +13,8 @@ import { } from '@repo/ui/components/ui/input-group' import { Button } from '@repo/ui/components/ui/button' import { Separator } from '@repo/ui/components/ui/separator' +import { Kbd } from '@repo/ui/components/ui/kbd' +import { Spinner } from '@repo/ui/components/ui/spinner' import { HoverCard, HoverCardContent, @@ -29,7 +31,6 @@ import { ScheduledForInput } from '../_.$orgid.enrollments.add/scheduled-for' import { Cell } from '../_.$orgid.enrollments.add/route' import { CoursePicker } from '../_.$orgid.enrollments.add/course-picker' import { UserPicker } from '../_.$orgid.enrollments.add/user-picker' -import { Kbd } from '@repo/ui/components/ui/kbd' const emptyRow = { user: undefined, @@ -38,10 +39,11 @@ const emptyRow = { } type AssignedProps = { + onSubmit: (value: any) => void | Promise courses: Promise<{ hits: Course[] }> } -export function Assigned({ courses }: AssignedProps) { +export function Assigned({ courses, onSubmit }: AssignedProps) { const { orgid } = useParams() const form = useForm({ resolver: zodResolver(formSchema), @@ -68,13 +70,13 @@ export function Assigned({ courses }: AssignedProps) { return hits } - const onSubmit = async (data: any) => { - console.log(data) + const onSubmit_ = async (data: any) => { + await onSubmit(data) } return ( - +
{/* Header */} <> @@ -291,7 +293,12 @@ export function Assigned({ courses }: AssignedProps) { - diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.checkout/bulk.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/bulk.tsx similarity index 80% rename from apps/admin.saladeaula.digital/app/routes/_.$orgid.checkout/bulk.tsx rename to apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/bulk.tsx index 041770b..34d5d73 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.checkout/bulk.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/bulk.tsx @@ -1,5 +1,5 @@ import { Fragment } from 'react' -import { MinusIcon, PlusIcon, Trash2Icon } from 'lucide-react' +import { MinusIcon, PlusIcon, Trash2Icon, XIcon } from 'lucide-react' import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form' import { ErrorMessage } from '@hookform/error-message' import { zodResolver } from '@hookform/resolvers/zod' @@ -11,20 +11,22 @@ import { InputGroupButton, InputGroupInput } from '@repo/ui/components/ui/input-group' -import { Input } from '@repo/ui/components/ui/input' import { Form } from '@repo/ui/components/ui/form' import { Button } from '@repo/ui/components/ui/button' import { Separator } from '@repo/ui/components/ui/separator' +import { Spinner } from '@repo/ui/components/ui/spinner' import { Cell } from '../_.$orgid.enrollments.add/route' import { CoursePicker } from '../_.$orgid.enrollments.add/course-picker' import { MAX_ITEMS, type Course } from '../_.$orgid.enrollments.add/data' +import { Discount } from './discount' const emptyRow = { course: undefined } type BulkProps = { + onSubmit: (value: any) => void | Promise courses: Promise<{ hits: Course[] }> } @@ -44,12 +46,19 @@ const item = 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({ + coupon: z.string(), + type: z.enum(['FIXED', 'PERCENT']), + amount: z.number().positive() + }) + .optional() }) type Schema = z.infer -export function Bulk({ courses }: BulkProps) { +export function Bulk({ courses, onSubmit }: BulkProps) { const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { items: [emptyRow] } @@ -71,6 +80,7 @@ export function Bulk({ courses }: BulkProps) { control, name: 'items' }) + const coupon = useWatch({ control, name: 'coupon' }) const subtotal = items.reduce( (acc, { course, quantity }) => acc + @@ -78,14 +88,18 @@ export function Bulk({ courses }: BulkProps) { (Number.isFinite(quantity) && quantity > 0 ? quantity : 1), 0 ) + const discount = coupon + ? applyDiscount(subtotal, coupon.amount, coupon.type) + : 0 + const total = subtotal > 0 ? subtotal - discount : 0 - const onSubmit = async (data: Schema) => { - console.log(data) + const onSubmit_ = async (data: Schema) => { + await onSubmit(data) } return (
- +
{/* Header */} <> @@ -272,6 +286,7 @@ export function Bulk({ courses }: BulkProps) { Subtotal
- Cupom + {coupon ? <>Descontos : <>Cupom}
+ - Cupom + {coupon ? <>Descontos : <>Cupom} + + - + {coupon ? ( + { + setValue('coupon', undefined) + }} + > + + + ) : ( + { + setValue('coupon', { + coupon: sk, + amount: discount_amount, + type: discount_type + }) + }} + /> + )} @@ -318,7 +353,8 @@ export function Bulk({ courses }: BulkProps) { @@ -328,7 +364,12 @@ export function Bulk({ courses }: BulkProps) { - @@ -340,3 +381,20 @@ const currency = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }) + +function applyDiscount( + subtotal: number, + discountAmount: number, + discountType: 'FIXED' | 'PERCENT' +) { + if (subtotal <= 0) { + return 0 + } + + const amount = + discountType === 'PERCENT' + ? (subtotal * discountAmount) / 100 + : discountAmount + + return Math.min(amount, subtotal) +} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/discount.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/discount.tsx new file mode 100644 index 0000000..633b110 --- /dev/null +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/discount.tsx @@ -0,0 +1,135 @@ +import type { InputHTMLAttributes } from 'react' +import { useRequest, useToggle } from 'ahooks' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' + +import { Button } from '@repo/ui/components/ui/button' +import { Input } from '@repo/ui/components/ui/input' +import { Spinner } from '@repo/ui/components/ui/spinner' +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@repo/ui/components/ui/popover' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@repo/ui/components/ui/form' + +export const formSchema = z.object({ + coupon: z.string().min(3, { error: 'Digite um cupom válido' }).trim() +}) + +export type Schema = z.infer + +interface DiscountProps extends Omit< + InputHTMLAttributes, + 'value' | 'onChange' +> { + onChange?: (value: any) => void +} + +export function Discount({ onChange, ...props }: DiscountProps) { + const form = useForm({ + resolver: zodResolver(formSchema) + }) + const [open, { toggle, set }] = useToggle() + const { runAsync } = useRequest( + async (coupon) => { + return await fetch(`/~/api/coupons/${coupon}`, { + method: 'GET' + }) + }, + { manual: true } + ) + const { handleSubmit, control, formState, setError, reset } = form + + const onSubmit = async (data: Schema) => { + const r = await runAsync(data.coupon) + + if (!r.ok) { + return setError('coupon', { message: 'Cupom inválido' }) + } + onChange?.(await r.json()) + + reset() + set(false) + } + + return ( + + + + + + +
+ { + e.stopPropagation() + e.preventDefault() + await handleSubmit(onSubmit)(e) + }} + > +
+

Recebeu um cupom?

+

+ Aplique seu cupom e tenha acesso aos cursos com descontos + exclusivos. +

+ + ( + + Cupom + + + + + + )} + /> + +
+ + + +
+
+
+ +
+
+ ) +} 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 new file mode 100644 index 0000000..0b532ef --- /dev/null +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/route.tsx @@ -0,0 +1,122 @@ +import type { Route } from './+types/route' + +import { Link } from 'react-router' +import { useToggle } from 'ahooks' + +import { + Card, + CardContent, + CardHeader, + CardDescription, + CardTitle +} from '@repo/ui/components/ui/card' +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from '@repo/ui/components/ui/breadcrumb' +import { Switch } from '@repo/ui/components/ui/switch' +import { createSearch } from '@repo/util/meili' +import { cloudflareContext } from '@repo/auth/context' +import { Label } from '@repo/ui/components/ui/label' + +import { Assigned } from './assigned' +import { Bulk } from './bulk' +import type { Course } from '../_.$orgid.enrollments.add/data' + +export function meta({}: Route.MetaArgs) { + return [{ title: 'Comprar matrículas' }] +} + +export async function loader({ params, context, request }: Route.LoaderArgs) { + const cloudflare = context.get(cloudflareContext) + const courses = createSearch({ + index: 'saladeaula_courses', + sort: ['created_at:desc'], + filter: 'unlisted NOT EXISTS', + hitsPerPage: 100, + env: cloudflare.env + }) + + return { courses } +} + +export async function action({ request }: Route.ActionArgs) { + const body = (await request.json()) as object + console.log(body) +} + +export default function Route({ + loaderData: { courses } +}: Route.ComponentProps) { + const [state, { toggle }] = useToggle('bulk', 'assigned') + + const onSubmit = async (data: any) => { + await new Promise((r) => setTimeout(r, 2000)) + console.log(data) + } + + const props = { courses, onSubmit } + + return ( +
+ + + + + Matrículas + + + + + Comprar matrículas + + + + +
+ + + Comprar matrículas + + Siga os passos abaixo para comprar novas matrículas. + + + + + + + {state == 'assigned' ? ( + + ) : ( + + )} + + +
+
+ ) +} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx index d8d084f..9fe6ad1 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx @@ -101,6 +101,7 @@ export default function Route({ loaderData }: Route.ComponentProps) {
+ ({ - className -}: { - className?: string -}) { +export function DataTableViewOptions({ className }: { className?: string }) { const { table } = useDataTable() return ( @@ -26,7 +24,12 @@ export function DataTableViewOptions({ Colunas - + + + Exibir colunas + + + {table .getAllColumns() .filter( @@ -43,6 +46,7 @@ export function DataTableViewOptions({ checked={column.getIsVisible()} onSelect={(e) => e.preventDefault()} onCheckedChange={(value) => column.toggleVisibility(!!value)} + className="cursor-pointer" > {title}