399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import { useForm, Controller, useWatch, type Control } from 'react-hook-form'
|
|
import { PatternFormat } from 'react-number-format'
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { ErrorMessage } from '@hookform/error-message'
|
|
import { z } from 'zod'
|
|
import { ArrowRightIcon, CircleQuestionMarkIcon } from 'lucide-react'
|
|
import valid from 'card-validator'
|
|
|
|
import { Button } from '@repo/ui/components/ui/button'
|
|
import { Kbd } from '@repo/ui/components/ui/kbd'
|
|
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'
|
|
import { Input } from '@repo/ui/components/ui/input'
|
|
import { Card, CardContent } from '@repo/ui/components/ui/card'
|
|
import {
|
|
HoverCard,
|
|
HoverCardContent,
|
|
HoverCardTrigger
|
|
} from '@repo/ui/components/ui/hover-card'
|
|
import {
|
|
Field,
|
|
FieldError,
|
|
FieldGroup,
|
|
FieldLabel,
|
|
FieldSet
|
|
} from '@repo/ui/components/ui/field'
|
|
import {
|
|
NativeSelect,
|
|
NativeSelectOption
|
|
} from '@repo/ui/components/ui/native-select'
|
|
import { Currency } from '@repo/ui/components/currency'
|
|
|
|
import { useWizard } from '@/components/wizard'
|
|
import { isName } from '../_.$orgid.users.add/data'
|
|
import { applyDiscount } from './discount'
|
|
import { useWizardStore } from './store'
|
|
|
|
const creditCard = z.object({
|
|
holder_name: z
|
|
.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_year: z.string().min(4),
|
|
cvv: z.string().min(3).max(4)
|
|
})
|
|
|
|
const formSchema = z.discriminatedUnion(
|
|
'payment_method',
|
|
[
|
|
z.object({
|
|
payment_method: z.literal('PIX')
|
|
}),
|
|
|
|
z.object({
|
|
payment_method: z.literal('BANK_SLIP')
|
|
}),
|
|
|
|
z.object({
|
|
payment_method: z.literal('MANUAL')
|
|
}),
|
|
|
|
z.object({
|
|
payment_method: z.literal('CREDIT_CARD'),
|
|
credit_card: creditCard,
|
|
installments: z.coerce.number().int().min(1).max(12)
|
|
})
|
|
],
|
|
{ error: 'Escolha uma forma de pagamento' }
|
|
)
|
|
|
|
type Schema = z.input<typeof formSchema>
|
|
|
|
export type CreditCard = z.infer<typeof creditCard>
|
|
|
|
export function Payment({}) {
|
|
const wizard = useWizard()
|
|
const { update, summary, ...state } = useWizardStore()
|
|
const { total } = summary()
|
|
const { control, handleSubmit } = useForm<Schema>({
|
|
defaultValues: {
|
|
payment_method: state.payment_method,
|
|
installments: state.installments,
|
|
credit_card: state.credit_card
|
|
},
|
|
resolver: zodResolver(formSchema)
|
|
})
|
|
const paymentMethod = useWatch({ control, name: 'payment_method' })
|
|
|
|
const onSubmit = async ({ payment_method, ...data }: Schema) => {
|
|
if (payment_method === 'CREDIT_CARD') {
|
|
// @ts-ignore
|
|
update({ payment_method, ...data })
|
|
} else {
|
|
update({
|
|
payment_method,
|
|
installments: undefined,
|
|
credit_card: undefined
|
|
})
|
|
}
|
|
|
|
wizard('review')
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
<Controller
|
|
name="payment_method"
|
|
control={control}
|
|
// @ts-ignore
|
|
defaultValue=""
|
|
render={({ field: { name, value, onChange }, formState }) => (
|
|
<div className="space-y-1.5">
|
|
<RadioGroup
|
|
value={value}
|
|
onValueChange={onChange}
|
|
className="lg:flex gap-3
|
|
*:p-5 *:border *:rounded-xl *:flex-1
|
|
*:cursor-pointer *:bg-accent/25
|
|
*:has-[button[data-state=checked]]:bg-accent"
|
|
>
|
|
<Label>
|
|
<RadioGroupItem value="PIX" />
|
|
<span>Pix</span>
|
|
</Label>
|
|
|
|
<Label>
|
|
<RadioGroupItem value="BANK_SLIP" />
|
|
<span>Boleto bancário</span>
|
|
</Label>
|
|
|
|
<Label>
|
|
<RadioGroupItem value="CREDIT_CARD" />
|
|
<span>Cartão de crédito</span>
|
|
</Label>
|
|
</RadioGroup>
|
|
|
|
<ErrorMessage
|
|
errors={formState.errors}
|
|
name={name}
|
|
render={({ message }) => (
|
|
<p className="text-destructive text-sm">{message}</p>
|
|
)}
|
|
/>
|
|
</div>
|
|
)}
|
|
/>
|
|
|
|
{paymentMethod === 'CREDIT_CARD' ? (
|
|
<CreditCard control={control} total={total} />
|
|
) : null}
|
|
|
|
<Separator />
|
|
|
|
<div className="flex justify-between gap-4 *:cursor-pointer">
|
|
<Button
|
|
type="button"
|
|
variant="link"
|
|
className="text-black dark:text-white"
|
|
onClick={() => wizard('cart')}
|
|
tabIndex={-1}
|
|
>
|
|
Voltar
|
|
</Button>
|
|
|
|
<Button type="submit" variant="secondary">
|
|
Continuar <ArrowRightIcon />
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
export function CreditCard({
|
|
total,
|
|
control
|
|
}: {
|
|
total: number
|
|
control: Control<Schema>
|
|
}) {
|
|
const currentYear = new Date().getFullYear()
|
|
const years = Array.from({ length: 10 }, (_, i) => currentYear + i)
|
|
|
|
return (
|
|
<Card className="lg:w-1/2">
|
|
<CardContent>
|
|
<FieldGroup>
|
|
<FieldSet>
|
|
{/* Credir card number */}
|
|
<Controller
|
|
control={control}
|
|
defaultValue=""
|
|
name="credit_card.number"
|
|
render={({ field: { onChange, ref, ...field }, fieldState }) => (
|
|
<Field data-invalid={fieldState.invalid}>
|
|
<FieldLabel htmlFor={field.name}>Número do cartão</FieldLabel>
|
|
<PatternFormat
|
|
id={field.name}
|
|
format="#### #### #### ####"
|
|
mask="_"
|
|
placeholder="•••• •••• •••• ••••"
|
|
customInput={Input}
|
|
getInputRef={ref}
|
|
aria-invalid={fieldState.invalid}
|
|
onValueChange={({ value }) => {
|
|
onChange(value)
|
|
}}
|
|
{...field}
|
|
/>
|
|
|
|
{fieldState.invalid && (
|
|
<FieldError errors={[fieldState.error]} />
|
|
)}
|
|
</Field>
|
|
)}
|
|
/>
|
|
{/* Holder name */}
|
|
<Controller
|
|
control={control}
|
|
name="credit_card.holder_name"
|
|
defaultValue=""
|
|
render={({ field, fieldState }) => (
|
|
<Field data-invalid={fieldState.invalid}>
|
|
<FieldLabel htmlFor={field.name}>Nome do titular</FieldLabel>
|
|
<Input
|
|
id={field.name}
|
|
aria-invalid={fieldState.invalid}
|
|
{...field}
|
|
/>
|
|
{fieldState.invalid && (
|
|
<FieldError errors={[fieldState.error]} />
|
|
)}
|
|
</Field>
|
|
)}
|
|
/>
|
|
|
|
<FieldSet className="grid grid-cols-3 gap-4">
|
|
<Controller
|
|
control={control}
|
|
name="credit_card.exp_month"
|
|
defaultValue=""
|
|
render={({ field, fieldState }) => (
|
|
<Field data-invalid={fieldState.invalid}>
|
|
<FieldLabel htmlFor={field.name}>Mês</FieldLabel>
|
|
<NativeSelect
|
|
id={field.name}
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
aria-invalid={fieldState.invalid}
|
|
>
|
|
<NativeSelectOption value="" disabled>
|
|
Selecione
|
|
</NativeSelectOption>
|
|
|
|
{Array.from({ length: 12 }, (_, i) => {
|
|
const v = String(i + 1).padStart(2, '0')
|
|
return (
|
|
<NativeSelectOption key={v} value={v}>
|
|
{v}
|
|
</NativeSelectOption>
|
|
)
|
|
})}
|
|
</NativeSelect>
|
|
</Field>
|
|
)}
|
|
/>
|
|
|
|
<Controller
|
|
control={control}
|
|
name="credit_card.exp_year"
|
|
defaultValue=""
|
|
render={({ field, fieldState }) => (
|
|
<Field data-invalid={fieldState.invalid}>
|
|
<FieldLabel htmlFor={field.name}>Ano</FieldLabel>
|
|
<NativeSelect
|
|
id={field.name}
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
aria-invalid={fieldState.invalid}
|
|
>
|
|
<NativeSelectOption value="" disabled>
|
|
Selecione
|
|
</NativeSelectOption>
|
|
|
|
{years.map((year) => (
|
|
<NativeSelectOption key={year} value={String(year)}>
|
|
{year}
|
|
</NativeSelectOption>
|
|
))}
|
|
</NativeSelect>
|
|
</Field>
|
|
)}
|
|
/>
|
|
|
|
<Controller
|
|
control={control}
|
|
name="credit_card.cvv"
|
|
defaultValue=""
|
|
render={({ field, fieldState }) => (
|
|
<Field data-invalid={fieldState.invalid}>
|
|
<FieldLabel htmlFor={field.name}>
|
|
CVC
|
|
<HoverCard openDelay={0}>
|
|
<HoverCardTrigger asChild>
|
|
<button type="button" tabIndex={-1}>
|
|
<CircleQuestionMarkIcon className="size-4 text-muted-foreground" />
|
|
</button>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent
|
|
align="end"
|
|
className="text-sm space-y-1.5 lg:w-78"
|
|
>
|
|
<p>
|
|
O <Kbd>CVC</Kbd> é o código de segurança do cartão
|
|
de crédito.
|
|
</p>
|
|
|
|
<p>
|
|
Ele fica no verso do cartão e geralmente possui{' '}
|
|
<Kbd>3 dígitos</Kbd> (ou <Kbd>4 dígitos</Kbd> na
|
|
frente, no caso do American Express).
|
|
</p>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
</FieldLabel>
|
|
<Input
|
|
id={field.name}
|
|
aria-invalid={fieldState.invalid}
|
|
{...field}
|
|
/>
|
|
</Field>
|
|
)}
|
|
/>
|
|
</FieldSet>
|
|
|
|
<Controller
|
|
control={control}
|
|
name="installments"
|
|
defaultValue={1}
|
|
render={({ field, fieldState }) => (
|
|
<Field data-invalid={fieldState.invalid}>
|
|
<FieldLabel htmlFor={field.name}>Parcelas</FieldLabel>
|
|
<NativeSelect
|
|
id={field.name}
|
|
value={String(field.value)}
|
|
onChange={field.onChange}
|
|
aria-invalid={fieldState.invalid}
|
|
>
|
|
{Array.from({ length: 12 }, (_, index) => {
|
|
const installment = index + 1 // 1 -> 12
|
|
|
|
if (installment === 1) {
|
|
return (
|
|
<NativeSelectOption key={installment} value={1}>
|
|
<Currency>{total}</Currency> à vista
|
|
</NativeSelectOption>
|
|
)
|
|
}
|
|
|
|
const value =
|
|
calcInterest(total, installment) / installment
|
|
|
|
return (
|
|
<NativeSelectOption
|
|
key={installment}
|
|
value={installment}
|
|
>
|
|
{installment}x <Currency>{value}</Currency> com juros
|
|
</NativeSelectOption>
|
|
)
|
|
})}
|
|
</NativeSelect>
|
|
</Field>
|
|
)}
|
|
/>
|
|
</FieldSet>
|
|
</FieldGroup>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
export const calcInterest = (total: number, installment: number) => {
|
|
const rate2to6 = 0.055
|
|
const rate7to12 = 0.0608
|
|
const rate = installment >= 7 ? rate7to12 : rate2to6
|
|
|
|
return total * ((1 - 0.0382) / (1 - rate))
|
|
}
|