update payment details

This commit is contained in:
2026-01-15 14:21:11 -03:00
parent 906dde027c
commit 5f53ffb4a7
7 changed files with 241 additions and 129 deletions

View File

@@ -1,21 +1,40 @@
import type { Route } from './+types/route' import type { Route } from './+types/route'
import { Fragment, use, useEffect, type ReactNode } from 'react'
import { useRequest, useToggle } from 'ahooks'
import { ErrorMessage } from '@hookform/error-message' import { ErrorMessage } from '@hookform/error-message'
import { zodResolver } from '@hookform/resolvers/zod'
import { useRequest, useToggle } from 'ahooks'
import { import {
CircleQuestionMarkIcon,
CopyIcon, CopyIcon,
CopyPlusIcon, CopyPlusIcon,
Trash2Icon,
PlusIcon,
EllipsisIcon, EllipsisIcon,
CircleQuestionMarkIcon PlusIcon,
Trash2Icon
} from 'lucide-react' } from 'lucide-react'
import { redirect, Link, useParams, useFetcher } from 'react-router'
import { Controller, useFieldArray, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { pick } from 'ramda' import { pick } from 'ramda'
import { Fragment, use, useEffect, type ReactNode } from 'react'
import { Controller, useFieldArray, useForm } from 'react-hook-form'
import { Link, redirect, useFetcher, useParams } from 'react-router'
import { cloudflareContext } from '@repo/auth/context'
import { DateTime } from '@repo/ui/components/datetime'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from '@repo/ui/components/ui/breadcrumb'
import { Button } from '@repo/ui/components/ui/button'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@repo/ui/components/ui/card'
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -28,51 +47,32 @@ import {
HoverCardContent, HoverCardContent,
HoverCardTrigger HoverCardTrigger
} from '@repo/ui/components/ui/hover-card' } from '@repo/ui/components/ui/hover-card'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from '@repo/ui/components/ui/breadcrumb'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@repo/ui/components/ui/card'
import { DateTime } from '@repo/ui/components/datetime'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { Input } from '@repo/ui/components/ui/input' import { Input } from '@repo/ui/components/ui/input'
import { Button } from '@repo/ui/components/ui/button' import { Kbd } from '@repo/ui/components/ui/kbd'
import { Separator } from '@repo/ui/components/ui/separator' import { Label } from '@repo/ui/components/ui/label'
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from '@repo/ui/components/ui/popover' } from '@repo/ui/components/ui/popover'
import { Kbd } from '@repo/ui/components/ui/kbd' import { Separator } from '@repo/ui/components/ui/separator'
import { Label } from '@repo/ui/components/ui/label' import { Spinner } from '@repo/ui/components/ui/spinner'
import { useIsMobile } from '@repo/ui/hooks/use-mobile'
import { createSearch } from '@repo/util/meili' import { createSearch } from '@repo/util/meili'
import { HttpMethod, request as req } from '@repo/util/request' import { HttpMethod, request as req } from '@repo/util/request'
import { useIsMobile } from '@repo/ui/hooks/use-mobile'
import { cloudflareContext } from '@repo/auth/context'
import { workspaceContext } from '@/middleware/workspace'
import { cn } from '@repo/ui/lib/utils'
import { CoursePicker } from './course-picker'
import { import {
MAX_ITEMS,
formSchema, formSchema,
MAX_ITEMS,
type Enrolled,
type Schema, type Schema,
type User, type User
type Enrolled
} from './data' } from './data'
import { ScheduledForInput } from './scheduled-for' import { ScheduledForInput } from './scheduled-for'
import { CoursePicker } from './course-picker'
import { UserPicker } from './user-picker' import { UserPicker } from './user-picker'
import { cn } from '@repo/ui/lib/utils'
import { workspaceContext } from '@/middleware/workspace'
const emptyRow = { const emptyRow = {
user: undefined, user: undefined,

View File

@@ -1,4 +1,5 @@
import { Fragment, useEffect } from 'react' import { ErrorMessage } from '@hookform/error-message'
import { zodResolver } from '@hookform/resolvers/zod'
import { import {
ArrowRightIcon, ArrowRightIcon,
MinusIcon, MinusIcon,
@@ -6,31 +7,30 @@ import {
Trash2Icon, Trash2Icon,
XIcon XIcon
} from 'lucide-react' } from 'lucide-react'
import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form' import { Fragment, useEffect } from 'react'
import { ErrorMessage } from '@hookform/error-message' import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { Abbr } from '@repo/ui/components/abbr'
import { Button } from '@repo/ui/components/ui/button'
import { Form } from '@repo/ui/components/ui/form'
import { import {
InputGroup, InputGroup,
InputGroupAddon, InputGroupAddon,
InputGroupButton, InputGroupButton,
InputGroupInput InputGroupInput
} from '@repo/ui/components/ui/input-group' } from '@repo/ui/components/ui/input-group'
import { Form } from '@repo/ui/components/ui/form' import { Kbd } from '@repo/ui/components/ui/kbd'
import { Button } from '@repo/ui/components/ui/button'
import { Separator } from '@repo/ui/components/ui/separator' import { Separator } from '@repo/ui/components/ui/separator'
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Abbr } from '@repo/ui/components/abbr'
import { Cell } from '../_.$orgid.enrollments.add/route' import { useWizard } from '@/components/wizard'
import { CoursePicker } from '../_.$orgid.enrollments.add/course-picker' import { CoursePicker } from '../_.$orgid.enrollments.add/course-picker'
import { MAX_ITEMS, type Course } from '../_.$orgid.enrollments.add/data' import { MAX_ITEMS, type Course } from '../_.$orgid.enrollments.add/data'
import { Discount, applyDiscount, type Coupon } from './discount' import { Cell } from '../_.$orgid.enrollments.add/route'
import { currency } from './utils' import { Discount } from './discount'
import { useWizard } from '@/components/wizard'
import { useWizardStore } from './store' import { useWizardStore } from './store'
import { currency } from './utils'
const emptyRow = { const emptyRow = {
course: undefined as any, course: undefined as any,

View File

@@ -8,6 +8,8 @@ import { z } from 'zod'
import valid from 'card-validator' import valid from 'card-validator'
import { Currency } from '@repo/ui/components/currency' import { Currency } from '@repo/ui/components/currency'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Abbr } from '@repo/ui/components/abbr'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
import { Separator } from '@repo/ui/components/ui/separator' import { Separator } from '@repo/ui/components/ui/separator'
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
@@ -73,7 +75,7 @@ type ReviewProps = {
export function Review({ onSubmit }: ReviewProps) { export function Review({ onSubmit }: ReviewProps) {
const wizard = useWizard() const wizard = useWizard()
const { items, summary, address } = useWizardStore() const { items, summary, address, coupon } = useWizardStore()
const { subtotal, discount, interest_amount, total } = summary() const { subtotal, discount, interest_amount, total } = summary()
const [loading, { set }] = useToggle() const [loading, { set }] = useToggle()
@@ -127,8 +129,15 @@ export function Review({ onSubmit }: ReviewProps) {
</TableRow> </TableRow>
{/* Discount */} {/* Discount */}
<TableRow> <TableRow>
<TableCell className="text-right" colSpan={3}> <TableCell colSpan={3}>
Descontos <span className="flex gap-1 justify-end">
Descontos
{coupon && (
<Kbd>
<Abbr maxLen={8}>{coupon.code}</Abbr>
</Kbd>
)}
</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Currency>{discount}</Currency> <Currency>{discount}</Currency>

View File

@@ -1,12 +1,15 @@
import type { Route } from './+types/route' import type { Route } from './+types/route'
import { formatCEP } from '@brazilian-utils/brazilian-utils' import { formatCEP } from '@brazilian-utils/brazilian-utils'
import { CalendarClockIcon, CalendarIcon, UserIcon } from 'lucide-react' import {
AlertCircleIcon,
ArrowLeftRightIcon,
HelpCircleIcon
} from 'lucide-react'
import { useEffect } from 'react' import { useEffect } from 'react'
import { Link } from 'react-router' import { Link } from 'react-router'
import { Currency } from '@repo/ui/components/currency' import { Currency } from '@repo/ui/components/currency'
import { DateTime } from '@repo/ui/components/datetime'
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -18,15 +21,12 @@ import {
import { import {
Card, Card,
CardContent, CardContent,
CardDescription,
CardFooter,
CardHeader, CardHeader,
CardTitle CardTitle
} from '@repo/ui/components/ui/card' } from '@repo/ui/components/ui/card'
import { import {
Item, Item,
ItemContent, ItemContent,
ItemDescription,
ItemGroup, ItemGroup,
ItemTitle ItemTitle
} from '@repo/ui/components/ui/item' } from '@repo/ui/components/ui/item'
@@ -39,8 +39,21 @@ import {
TableHeader, TableHeader,
TableRow TableRow
} from '@repo/ui/components/ui/table' } from '@repo/ui/components/ui/table'
import { paymentMethods } from '@repo/ui/routes/orders/data'
import { request as req } from '@repo/util/request' import { request as req } from '@repo/util/request'
import { Abbr } from '@repo/ui/components/abbr'
import { Badge } from '@repo/ui/components/ui/badge'
import { Button } from '@repo/ui/components/ui/button'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { cn } from '@repo/ui/lib/utils'
import {
labels,
statuses,
type Order as Order_
} from '@repo/ui/routes/orders/data'
import type { CreditCard as CreditCard_ } from '../_.$orgid.enrollments.buy/payment'
import type { Address } from '../_.$orgid.enrollments.buy/review'
import { useWizardStore } from '../_.$orgid.enrollments.buy/store' import { useWizardStore } from '../_.$orgid.enrollments.buy/store'
export function meta() { export function meta() {
@@ -51,12 +64,44 @@ export function meta() {
] ]
} }
const PaymentMethodComponent = {
PIX: Pix,
BANK_SLIP: BankSlip,
CREDIT_CARD: CreditCard
}
type Item_ = {
id: string
name: string
unit_price: number
quantity: number
}
type User = {
id: string
name: string
}
type Order = Order_ & {
items: Item_[]
interest_amount: number
due_date: string
created_at: string
subtotal: number
discount: number
address: Address
credit_card?: CreditCard_
coupon?: string
installments?: number
created_by?: User
}
export async function loader({ context, request, params }: Route.LoaderArgs) { export async function loader({ context, request, params }: Route.LoaderArgs) {
const order = await req({ const order = (await req({
url: `/orders/${params.id}`, url: `/orders/${params.id}`,
context, context,
request request
}).then((r) => r.json()) }).then((r) => r.json())) as Order
return { order } return { order }
} }
@@ -64,22 +109,18 @@ export async function loader({ context, request, params }: Route.LoaderArgs) {
export default function Route({ loaderData: { order } }: Route.ComponentProps) { export default function Route({ loaderData: { order } }: Route.ComponentProps) {
const { reset } = useWizardStore() const { reset } = useWizardStore()
const { const {
status, coupon,
address, address,
total, total,
credit_card,
payment_method, payment_method,
due_date,
created_at,
invoice,
installments,
interest_amount, interest_amount,
discount, discount,
subtotal, subtotal,
items = [], items = []
created_by
} = order } = order
const Component = PaymentMethodComponent[payment_method]
useEffect(() => { useEffect(() => {
reset() reset()
}, []) }, [])
@@ -103,14 +144,6 @@ export default function Route({ loaderData: { order } }: Route.ComponentProps) {
<Card className="lg:max-w-4xl mx-auto"> <Card className="lg:max-w-4xl mx-auto">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl">Detalhes do pagamento</CardTitle> <CardTitle className="text-2xl">Detalhes do pagamento</CardTitle>
{status}
<CardDescription>
<ul className="flex gap-2.5 *:flex *:gap-1 *:items-center [&_svg]:size-3.5">
<li>
<CalendarClockIcon /> <DateTime>{due_date}</DateTime>
</li>
</ul>
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -134,26 +167,12 @@ export default function Route({ loaderData: { order } }: Route.ComponentProps) {
<Item variant="outline" className="items-start"> <Item variant="outline" className="items-start">
<ItemContent> <ItemContent>
<ItemTitle>Forma de pagamento</ItemTitle> <ItemTitle>Forma de pagamento</ItemTitle>
<ItemDescription> <div className="text-muted-foreground text-sm leading-normal font-normal text-balance">
{credit_card ? ( {Component && <Component {...order} />}
<> </div>
{credit_card.brand} (Crédito) **** {credit_card.last4}
<br />
{installments}x{' '}
<Currency>{total / Number(installments)}</Currency>
</>
) : (
<>
{payment_method
? paymentMethods[payment_method]
: payment_method}
</>
)}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
</ItemGroup> </ItemGroup>
{/*<pre>{JSON.stringify(order, null, 2)}</pre>*/}
<Table className="pointer-events-none"> <Table className="pointer-events-none">
<TableHeader> <TableHeader>
@@ -193,8 +212,15 @@ export default function Route({ loaderData: { order } }: Route.ComponentProps) {
</TableRow> </TableRow>
{/* Discount */} {/* Discount */}
<TableRow> <TableRow>
<TableCell className="text-right" colSpan={3}> <TableCell colSpan={3}>
Descontos <span className="flex gap-1 justify-end">
Descontos
{coupon && (
<Kbd>
<Abbr maxLen={8}>{coupon}</Abbr>
</Kbd>
)}
</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Currency>{discount}</Currency> <Currency>{discount}</Currency>
@@ -225,18 +251,93 @@ export default function Route({ loaderData: { order } }: Route.ComponentProps) {
</TableFooter> </TableFooter>
</Table> </Table>
</CardContent> </CardContent>
{/* <CardFooter>
<ul className="flex gap-2.5 *:flex *:gap-1 *:items-center [&_svg]:size-3.5">
<li>
<CalendarIcon /> <DateTime>{created_at}</DateTime>
</li>{' '}
<li>
<UserIcon /> {created_by.name}
</li>
</ul>
</CardFooter>*/}
</Card> </Card>
{/*<pre>{JSON.stringify(order, null, 2)}</pre>*/}
</div> </div>
) )
} }
function Status({ status: s }: { status: string }) {
const status = labels[s] ?? s
const { icon: Icon, color } = statuses?.[s] ?? { icon: HelpCircleIcon }
return (
<Badge variant="outline" className={cn(color, ' px-1.5')}>
<Icon className={cn('stroke-2', color)} />
{status}
</Badge>
)
}
type PaymentMethodProps = {
status: string
total: number
installments: number
}
type CreditCardProps = PaymentMethodProps & {
stats: { last_attempt_succeeded: boolean }
credit_card: { last4: string; brand: string }
}
function CreditCard({
status,
total,
credit_card,
installments,
stats
}: CreditCardProps) {
return (
<>
<ul className="flex max-lg:flex-col gap-x-1.5">
<li>
{credit_card.brand} (Crédito) **** {credit_card.last4}
</li>
<li>
{!stats.last_attempt_succeeded ? (
<Badge
variant="outline"
className="text-red-400 border-red-400 px-1.5"
>
<AlertCircleIcon /> Transação negada
</Badge>
) : (
<Status status={status} />
)}
</li>
</ul>
<p>
{installments}x <Currency>{total / Number(installments)}</Currency>
</p>
{!stats.last_attempt_succeeded ? (
<div className="flex justify-center mt-2">
<Button size="sm" variant="secondary" className="cursor-pointer">
<ArrowLeftRightIcon /> Tentar com outro cartão
</Button>
</div>
) : null}
</>
)
}
type BankSlipProps = PaymentMethodProps & {}
function BankSlip({ status }: BankSlipProps) {
return (
<ul className="flex max-lg:flex-col gap-x-1.5">
<li>Boleto bancário</li>
<li>
<Status status={status} />
</li>
</ul>
)
}
type PixProps = PaymentMethodProps & {}
function Pix({}: PixProps) {
return <>Pix</>
}

View File

@@ -2,10 +2,10 @@
import { type ColumnDef } from '@tanstack/react-table' import { type ColumnDef } from '@tanstack/react-table'
import { // import {
DataTableColumnHeaderSelect, // DataTableColumnHeaderSelect,
DataTableColumnSelect // DataTableColumnSelect
} from '@repo/ui/components/data-table' // } from '@repo/ui/components/data-table'
import { columns as columns_, type Order } from '@repo/ui/routes/orders/columns' import { columns as columns_, type Order } from '@repo/ui/routes/orders/columns'
export type { Order } export type { Order }

View File

@@ -44,21 +44,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
) )
with dyn.transact_writer() as transact: with dyn.transact_writer() as transact:
transact.delete(key=KeyPair(order_id, 'TRANSACTION'))
transact.update(
key=KeyPair(order_id, 'TRANSACTION#STATS'),
update_expr='SET #count = if_not_exists(#count, :zero) + :one, \
updated_at = :now',
expr_attr_names={
'#count': 'payment_attempts',
},
expr_attr_values={
':zero': 0,
':one': 1,
':now': now(),
},
)
if charge['success'] is True: if charge['success'] is True:
transact.update( transact.update(
key=KeyPair(order_id, '0'), key=KeyPair(order_id, '0'),
@@ -97,4 +82,21 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
}, },
) )
transact.delete(key=KeyPair(order_id, 'TRANSACTION'))
transact.update(
key=KeyPair(order_id, 'TRANSACTION#STATS'),
update_expr='SET #count = if_not_exists(#count, :zero) + :one, \
last_attempt_succeeded = :succeeded, \
updated_at = :now',
expr_attr_names={
'#count': 'payment_attempts',
},
expr_attr_values={
':succeeded': charge['success'],
':zero': 0,
':one': 1,
':now': now(),
},
)
return charge['success'] return charge['success']

View File

@@ -1,15 +1,15 @@
'use client' 'use client'
import { import {
DataTableColumnDatetime,
DataTableColumnCurrency, DataTableColumnCurrency,
DataTableColumnDatetime,
DataTableColumnHeaderSort DataTableColumnHeaderSort
} from '@repo/ui/components/data-table' } from '@repo/ui/components/data-table'
import { type ColumnDef } from '@tanstack/react-table' import { type ColumnDef } from '@tanstack/react-table'
import { HelpCircleIcon } from 'lucide-react' import { HelpCircleIcon } from 'lucide-react'
import { cn } from '@repo/ui/lib/utils'
import { Badge } from '@repo/ui/components/ui/badge' import { Badge } from '@repo/ui/components/ui/badge'
import { cn } from '@repo/ui/lib/utils'
import { labels, paymentMethods, statuses, type Order } from './data' import { labels, paymentMethods, statuses, type Order } from './data'
@@ -33,7 +33,7 @@ export const columns: ColumnDef<Order>[] = [
const { icon: Icon, color } = statuses?.[s] ?? { icon: HelpCircleIcon } const { icon: Icon, color } = statuses?.[s] ?? { icon: HelpCircleIcon }
return ( return (
<Badge variant="outline" className={cn(color, ' px-1.5')}> <Badge variant="outline" className={cn(color, 'px-1.5')}>
<Icon className={cn('stroke-2', color)} /> <Icon className={cn('stroke-2', color)} />
{status} {status}
</Badge> </Badge>