update credit card mask

This commit is contained in:
2025-12-27 18:43:08 -03:00
parent d0dcc0a953
commit 976a7da0a9
10 changed files with 268 additions and 93 deletions

View File

@@ -339,11 +339,7 @@ export function Bulk({
type SummaryProps = { type SummaryProps = {
subtotal: number subtotal: number
coupon?: { coupon?: Coupon
code: string
type: 'FIXED' | 'PERCENT'
amount: number
}
setValue: UseFormSetValue<any> setValue: UseFormSetValue<any>
} }
@@ -411,7 +407,7 @@ export function Summary({ subtotal, coupon, setValue }: SummaryProps) {
tabIndex={-1} tabIndex={-1}
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setValue('coupon', undefined) setValue('coupon', null)
}} }}
> >
<XIcon /> <XIcon />
@@ -420,11 +416,7 @@ export function Summary({ subtotal, coupon, setValue }: SummaryProps) {
<Discount <Discount
disabled={subtotal === 0} disabled={subtotal === 0}
onChange={(coupon) => { onChange={(coupon) => {
setValue('coupon', { setValue('coupon', coupon)
// code: sk,
// amount: discount_amount,
// type: discount_type
})
}} }}
/> />
)} )}

View File

@@ -72,6 +72,8 @@ 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

@@ -2,8 +2,9 @@ import { useForm, Controller, useWatch, type Control } from 'react-hook-form'
import { PatternFormat } from 'react-number-format' import { PatternFormat } from 'react-number-format'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { ErrorMessage } from '@hookform/error-message' import { ErrorMessage } from '@hookform/error-message'
import z from 'zod' import { z } from 'zod'
import { ArrowRightIcon, CircleQuestionMarkIcon } from 'lucide-react' import { ArrowRightIcon, CircleQuestionMarkIcon } from 'lucide-react'
import valid from 'card-validator'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
import { Kbd } from '@repo/ui/components/ui/kbd' import { Kbd } from '@repo/ui/components/ui/kbd'
@@ -19,6 +20,7 @@ import {
} from '@repo/ui/components/ui/hover-card' } from '@repo/ui/components/ui/hover-card'
import { import {
Field, Field,
FieldError,
FieldGroup, FieldGroup,
FieldLabel, FieldLabel,
FieldSet FieldSet
@@ -29,10 +31,21 @@ import {
} from '@repo/ui/components/ui/native-select' } from '@repo/ui/components/ui/native-select'
import { useWizard } from '@/components/wizard' import { useWizard } from '@/components/wizard'
import { isName } from '../_.$orgid.users.add/data'
const creditCard = z.object({ const creditCard = z.object({
holder_name: z.string().min(1), holder_name: z
number: z.string().min(13).max(19), .string()
.trim()
.nonempty('Digite um nome')
.refine(isName, { message: 'Nome inválido' }),
number: z.string().refine(
(value) => {
const numberValidation = valid.number(value)
return numberValidation.isValid
},
{ error: 'Número do cartão inválido' }
),
exp_month: z.string().min(2), exp_month: z.string().min(2),
exp_year: z.string().min(4), exp_year: z.string().min(4),
cvv: z.string().min(3).max(4) cvv: z.string().min(3).max(4)
@@ -90,6 +103,8 @@ export function Payment({
<Controller <Controller
name="payment_method" name="payment_method"
control={control} control={control}
// @ts-ignore
defaultValue=""
render={({ field: { name, value, onChange }, formState }) => ( render={({ field: { name, value, onChange }, formState }) => (
<div className="space-y-1.5"> <div className="space-y-1.5">
<RadioGroup <RadioGroup
@@ -143,6 +158,7 @@ export function Payment({
> >
Voltar Voltar
</Button> </Button>
<Button type="submit" variant="secondary"> <Button type="submit" variant="secondary">
Continuar <ArrowRightIcon /> Continuar <ArrowRightIcon />
</Button> </Button>
@@ -161,6 +177,7 @@ export function CreditCard({ control }: { control: Control<Schema> }) {
<FieldGroup> <FieldGroup>
<FieldSet> <FieldSet>
<FieldGroup> <FieldGroup>
{/* Credir card number */}
<Controller <Controller
control={control} control={control}
name="credit_card.number" name="credit_card.number"
@@ -185,10 +202,14 @@ export function CreditCard({ control }: { control: Control<Schema> }) {
}} }}
{...field} {...field}
/> />
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field> </Field>
)} )}
/> />
{/* Holder name */}
<Controller <Controller
control={control} control={control}
name="credit_card.holder_name" name="credit_card.holder_name"
@@ -203,6 +224,9 @@ export function CreditCard({ control }: { control: Control<Schema> }) {
aria-invalid={fieldState.invalid} aria-invalid={fieldState.invalid}
{...field} {...field}
/> />
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field> </Field>
)} )}
/> />

View File

@@ -1,6 +1,12 @@
import { useToggle } from 'ahooks'
import { PencilIcon } from 'lucide-react'
import { useForm } from 'react-hook-form'
import valid from 'card-validator'
import { Currency } from '@repo/ui/components/currency' import { Currency } from '@repo/ui/components/currency'
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 { import {
Table, Table,
TableBody, TableBody,
@@ -10,18 +16,38 @@ import {
TableHeader, TableHeader,
TableRow TableRow
} from '@repo/ui/components/ui/table' } from '@repo/ui/components/ui/table'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle
} from '@repo/ui/components/ui/item'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@repo/ui/components/ui/dialog'
import { useWizard } from '@/components/wizard' import { useWizard } from '@/components/wizard'
import { type WizardState } from './route' import { type WizardState } from './route'
import { applyDiscount } from './discount' import { applyDiscount } from './discount'
import { paymentMethods } from '@repo/ui/routes/orders/data'
type ReviewProps = { type ReviewProps = {
state: WizardState state: WizardState
onSubmit: (value: WizardState) => void | Promise<void>
} }
export function Review({ state }: ReviewProps) { export function Review({ state, onSubmit }: ReviewProps) {
const wizard = useWizard() const wizard = useWizard()
const [loading, { set }] = useToggle()
const { coupon, items } = state || { items: [], coupon: {} } const { coupon, items } = state || { items: [], coupon: {} }
const subtotal = const subtotal =
items?.reduce( items?.reduce(
@@ -36,79 +62,180 @@ export function Review({ state }: ReviewProps) {
: 0 : 0
const total = subtotal > 0 ? subtotal + discount : 0 const total = subtotal > 0 ? subtotal + discount : 0
console.log(state)
return ( return (
<> <>
<Table> <Address {...state} />
<TableHeader>
<TableRow className="pointer-events-none">
<TableHead>Curso</TableHead>
<TableHead>Quantidade</TableHead>
<TableHead>Valor unit.</TableHead>
<TableHead>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items?.map(({ course, quantity }, index) => {
return (
<TableRow key={index}>
<TableCell>{course.name}</TableCell>
<TableCell>{quantity}</TableCell>
<TableCell>
<Currency>{course.unit_price}</Currency>
</TableCell>
<TableCell>
<Currency>{course.unit_price * quantity}</Currency>
</TableCell>
</TableRow>
)
})}
</TableBody>
<TableFooter>
<TableRow className="pointer-events-none">
<TableCell className="text-right" colSpan={3}>
Subtotal
</TableCell>
<TableCell>
<Currency>{subtotal}</Currency>
</TableCell>
</TableRow>
<TableRow className="pointer-events-none">
<TableCell className="text-right" colSpan={3}>
Descontos
</TableCell>
<TableCell>
<Currency>{discount}</Currency>
</TableCell>
</TableRow>
<TableRow className="pointer-events-none">
<TableCell className="text-right" colSpan={3}>
Total
</TableCell>
<TableCell>
<Currency>{total}</Currency>
</TableCell>
</TableRow>
</TableFooter>
</Table>
<Separator /> <form
onSubmit={async (e) => {
e.preventDefault()
set(true)
await onSubmit(state)
}}
className="space-y-4"
>
<Table className="pointer-events-none">
<TableHeader>
<TableRow>
<TableHead>Curso</TableHead>
<TableHead>Quantidade</TableHead>
<TableHead>Valor unit.</TableHead>
<TableHead>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items?.map(({ course, quantity }, index) => {
return (
<TableRow key={index}>
<TableCell>{course.name}</TableCell>
<TableCell>{quantity}</TableCell>
<TableCell>
<Currency>{course.unit_price}</Currency>
</TableCell>
<TableCell>
<Currency>{course.unit_price * quantity}</Currency>
</TableCell>
</TableRow>
)
})}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="text-right" colSpan={3}>
Subtotal
</TableCell>
<TableCell>
<Currency>{subtotal}</Currency>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="text-right" colSpan={3}>
Descontos
</TableCell>
<TableCell>
<Currency>{discount}</Currency>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="text-right" colSpan={3}>
Total
</TableCell>
<TableCell>
<Currency>{total}</Currency>
</TableCell>
</TableRow>
</TableFooter>
</Table>
<div className="flex justify-between gap-4 *:cursor-pointer"> <Separator />
<Button
type="button" <div className="flex justify-between gap-4 *:cursor-pointer">
variant="link" <Button
className="text-black dark:text-white" type="button"
onClick={() => wizard('payment')} variant="link"
tabIndex={-1} className="text-black dark:text-white"
> onClick={() => wizard('payment')}
Voltar tabIndex={-1}
</Button> >
<Button type="submit"> Voltar
Pagar <Currency>{total}</Currency> </Button>
</Button>
</div> <Button type="submit" disabled={loading}>
{loading && <Spinner />}
Pagar <Currency>{total}</Currency>
</Button>
</div>
</form>
</> </>
) )
} }
export function Address({ payment_method, credit_card }: WizardState) {
const numberValidation = valid.number(credit_card?.number)
// console.log(numberValidation)
return (
<ItemGroup className="grid lg:grid-cols-2 gap-4">
<Item variant="outline" className="items-start">
<ItemContent>
<ItemTitle>Endereço de cobrança</ItemTitle>
<ul className="text-muted-foreground text-sm leading-normal font-normal text-balance">
Rua Monsenhor Ivo Zanlorenzi, 5190, ap 1802
<br />
Cidade Industrial
<br />
Curitiba, Paraná
<br />
81280-350
</ul>
</ItemContent>
<ItemActions>
<DialogDemo />
</ItemActions>
</Item>
<Item variant="outline" className="items-start">
<ItemContent>
<ItemTitle>Forma de pagamento</ItemTitle>
<ItemDescription>
{payment_method ? paymentMethods[payment_method] : payment_method}
{credit_card ? (
<>
<br />
{numberValidation.card?.niceType} ****{' '}
{credit_card.number.slice(-4)}
</>
) : null}
</ItemDescription>
</ItemContent>
</Item>
</ItemGroup>
)
}
export function DialogDemo() {
const form = useForm()
const { handleSubmit } = form
const onSubmit = async () => {}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
type="button"
className="text-muted-foreground cursor-pointer"
size="icon-sm"
>
<PencilIcon />
<span className="sr-only">Editar endereço</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Editar endereço</DialogTitle>
<DialogDescription>
Este endereço será utilizado para a emissão da NFS-e.
</DialogDescription>
</DialogHeader>
<DialogFooter className="*:cursor-pointer">
<DialogClose asChild>
<Button
variant="link"
type="button"
className="text-black dark:text-white"
>
Cancel
</Button>
</DialogClose>
<Button type="submit">Atualizar endereço</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
)
}

View File

@@ -25,6 +25,7 @@ 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 { Wizard, WizardStep } from '@/components/wizard'
import { Step, StepItem, StepSeparator } from '@/components/step' import { Step, StepItem, StepSeparator } from '@/components/step'
@@ -34,6 +35,7 @@ import { Bulk, type Item } from './bulk'
import { Payment, type CreditCard } from './payment' import { Payment, type CreditCard } from './payment'
import { Review } from './review' import { Review } from './review'
import type { Coupon } from './discount' import type { Coupon } from './discount'
import { useFetcher } from 'react-router'
export type WizardState = { export type WizardState = {
index: number index: number
@@ -41,7 +43,7 @@ export type WizardState = {
items: Item[] items: Item[]
enrollments: Enrollment[] enrollments: Enrollment[]
coupon?: Coupon coupon?: Coupon
payment_method?: 'PIX' | 'BANK_SLIP' | 'CREDIT_CARD' payment_method?: PaymentMethod
credit_card?: CreditCard credit_card?: CreditCard
} }
@@ -74,12 +76,14 @@ export async function loader({ context }: Route.LoaderArgs) {
export async function action({ request }: Route.ActionArgs) { export async function action({ request }: Route.ActionArgs) {
const body = (await request.json()) as object const body = (await request.json()) as object
console.log(body) console.log(body)
} }
export default function Route({ export default function Route({
loaderData: { courses } loaderData: { courses }
}: Route.ComponentProps) { }: Route.ComponentProps) {
const fetcher = useFetcher()
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const [state, setState] = useLocalStorageState<WizardState>('wizard_cart', { const [state, setState] = useLocalStorageState<WizardState>('wizard_cart', {
defaultValue: emptyWizard defaultValue: emptyWizard
@@ -96,6 +100,13 @@ export default function Route({
})) }))
} }
const onSubmit = async (data: WizardState) => {
await fetcher.submit(JSON.stringify(data), {
method: 'post',
encType: 'application/json'
})
}
useMount(() => { useMount(() => {
setMounted(true) setMounted(true)
}) })
@@ -206,7 +217,7 @@ export default function Route({
{/* Review */} {/* Review */}
<WizardStep name="review"> <WizardStep name="review">
<Review state={state} /> <Review state={state} onSubmit={onSubmit} />
</WizardStep> </WizardStep>
</Wizard> </Wizard>
</CardContent> </CardContent>

View File

@@ -7,7 +7,7 @@ import {
} from 'unique-names-generator' } from 'unique-names-generator'
import { z } from 'zod' import { z } from 'zod'
const isName = (name: string) => name && name.includes(' ') export const isName = (name: string) => name && name.includes(' ')
function randomEmail() { function randomEmail() {
const numberDict = NumberDictionary.generate({ min: 100, max: 999 }) const numberDict = NumberDictionary.generate({ min: 100, max: 999 })

View File

@@ -17,6 +17,7 @@
"@repo/ui": "*", "@repo/ui": "*",
"@repo/util": "^0.0.0", "@repo/util": "^0.0.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"card-validator": "^10.0.3",
"cookie": "^1.1.1", "cookie": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"flat": "^6.0.1", "flat": "^6.0.1",

16
package-lock.json generated
View File

@@ -30,6 +30,7 @@
"@repo/ui": "*", "@repo/ui": "*",
"@repo/util": "^0.0.0", "@repo/util": "^0.0.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"card-validator": "^10.0.3",
"cookie": "^1.1.1", "cookie": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"flat": "^6.0.1", "flat": "^6.0.1",
@@ -4783,6 +4784,15 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/card-validator": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/card-validator/-/card-validator-10.0.3.tgz",
"integrity": "sha512-xOEDsK3hojV0OIpmrR64eZGpngnOqRDEP20O+WSRtvjLSW6nyekW4i2N9SzYg679uFO3RyHcFHxb+mml5tXc4A==",
"license": "MIT",
"dependencies": {
"credit-card-type": "^10.0.2"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -4905,6 +4915,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/credit-card-type": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/credit-card-type/-/credit-card-type-10.1.0.tgz",
"integrity": "sha512-yd9ebGqWQ9zDvxrvCOkX80GiTvVJ28940uIMqRA58Cu3ReiPkOS4p45d9Y2cmJZtZgp90WdwLSiUtI8lHDgE2g==",
"license": "MIT"
},
"node_modules/crypto-js": { "node_modules/crypto-js": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",

View File

@@ -11,7 +11,7 @@ import { HelpCircleIcon } from 'lucide-react'
import { cn } from '@repo/ui/lib/utils' import { cn } from '@repo/ui/lib/utils'
import { Badge } from '@repo/ui/components/ui/badge' import { Badge } from '@repo/ui/components/ui/badge'
import { labels, methods, statuses, type Order } from './data' import { labels, paymentMethods, statuses, type Order } from './data'
export type { Order } export type { Order }
@@ -21,7 +21,7 @@ export const columns: ColumnDef<Order>[] = [
header: 'Forma de pag.', header: 'Forma de pag.',
cell: ({ row }) => { cell: ({ row }) => {
const paymentMethod = row.getValue('payment_method') as string const paymentMethod = row.getValue('payment_method') as string
return <>{methods[paymentMethod] ?? paymentMethod}</> return <>{paymentMethods[paymentMethod] ?? paymentMethod}</>
} }
}, },
{ {

View File

@@ -8,13 +8,15 @@ import {
type LucideIcon type LucideIcon
} from 'lucide-react' } from 'lucide-react'
export type PaymentMethod = 'PIX' | 'BANK_SLIP' | 'CREDIT_CARD' | 'MANUAL'
// This type is used to define the shape of our data. // This type is used to define the shape of our data.
// You can use a Zod schema here if you want. // You can use a Zod schema here if you want.
export type Order = { export type Order = {
id: string id: string
total: number total: number
status: 'PENDING' | 'PAID' | 'DECLINED' | 'EXPIRED' | 'REFUNDED' | 'CANCELED' status: 'PENDING' | 'PAID' | 'DECLINED' | 'EXPIRED' | 'REFUNDED' | 'CANCELED'
payment_method: 'PIX' | 'CREDIT_CARD' | 'BANK_SLIP' | 'MANUAL' payment_method: PaymentMethod
name: string name: string
email: string email: string
} }
@@ -64,7 +66,7 @@ export const labels: Record<string, string> = {
CANCELED: 'Cancelado' CANCELED: 'Cancelado'
} }
export const methods: Record<string, string> = { export const paymentMethods: Record<string, string> = {
PIX: 'Pix', PIX: 'Pix',
CREDIT_CARD: 'Cartão de crédito', CREDIT_CARD: 'Cartão de crédito',
BANK_SLIP: 'Boleto bancário', BANK_SLIP: 'Boleto bancário',