diff --git a/.gitignore b/.gitignore index c6bba59..5ef6a52 100644 --- a/.gitignore +++ b/.gitignore @@ -1,130 +1,41 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions -# nyc test coverage -.nyc_output +# testing +/coverage -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt +# next.js +/.next/ +/out/ -# Bower dependency directory (https://bower.io/) -bower_components +# production +/build -# node-waf configuration -.lock-wscript +# misc +.DS_Store +*.pem -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* -# Dependency directories -node_modules/ -jspm_packages/ +# env files (can opt-in for committing if needed) +.env* -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ +# vercel +.vercel -# TypeScript cache +# typescript *.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* +next-env.d.ts diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..b4bfed3 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/README.md b/README.md index 1cf7b0a..6872ae2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Desafio: Checkout para Compensação de Carbono +# Desafio Neste desafio, você deverá recriar um layout de checkout fictício para compensação de carbono, conforme o design fornecido no Figma. Além disso, será necessário consumir dois endpoints da API: @@ -10,9 +10,10 @@ Ambos os endpoints devem ser integrados na aplicação de forma que a experiênc ## Requisitos - **URL do Checkout:** A aplicação deve utilizar um URL no seguinte formato: -````https://meu-teste.com/checkout?co2=1&cred=2```` + `https://meu-teste.com/checkout?co2=1&cred=2` Onde: + - **`co2`**: Representa a quantidade de carbono. - **`cred`**: Representa o valor unitario do crédito (ou creditPriceId). @@ -20,54 +21,47 @@ O valor do crédito de carbono, que será atribuído ao parâmetro `cred` (ou `c A requisição ao endpoint `Get Credit Price` é necessária para garantir que o valor do crédito esteja sempre atualizado e de acordo com as especificações do sistema. -FIGMA: -[LAYOUT](https://www.figma.com/design/3dbuGSs6oCSLkPw9viSsfT/Front-end-Teste?node-id=0-1&t=KvUCyOT5Miiuh0D5-1) +# Rodando o projeto localmente + +Você deverá clonar o repositório, depois rodar os comandos abaixo: -## Consumo da API Mock +``` +$ npm install +$ npm run dev -Use a API mock do Postman diponibilizada em /docs. +``` -### Importação da Coleção do Postman +**Obs:** Lembre-se de criar o arquivo .env e fornecer a URL da API -Siga as etapas abaixo para importar a coleção e testar os endpoints do checkout. +``` + NEXT_PUBLIC_API -1. Abra o Postman. -2. Clique no botão **Importar** no canto superior esquerdo. -![Onde clicar no postman para importar](./docs/image.png) -3. Selecione o arquivo `Carbon_Checkout.postman_collection.json` localizado na pasta `doc/`. +``` -![Como deve ficar apos a importação.](./docs/image-2.png) -4. Execute os testes com os parâmetros fornecidos para verificar o funcionamento correto da aplicação. +# API Mock ------------------------------------------------------ +A MockAPI utilizada para a aplicação está disponibilizada dentro da {{url}} do Postman mas para facilitar está aqui: -## FRAMEWORK +``` + https://6751f822d1983b9597b4fa68.mockapi.io/api -- [NEXT.JS](https://nextjs.org/docs) +``` ------------------------------------------------------ +# Tecnologias Utilizadas -## ESTILOS +- [Next.js](https://nextjs.org/) +- [Tailwindcss](https://tailwindcss.com/) +- [Shadcn/ui](https://ui.shadcn.com/) +- [React-hook-form](https://react-hook-form.com/) +- [Zod](https://zod.dev/) +- [@hookform/resolvers](https://www.npmjs.com/package/@hookform/resolvers/v/1.3.7) -- Os estilos deste teste devem ser feitos em styled-components (ou tailwind). -- O projeto ser o mais proximo possivel do layout fornecido. -- Deve ser totalmente responsivo. +--- -## REQUISITOS DIFERENCIAIS: +## Deploy na Vercel -- Back-end (para desenvolvedores full-stack) -- Código performático. -- Manutenibilidade do Código. -- Utilizar inglês no projeto todo. -- Fazer deploy do mesmo (heroku, netlify, aws, vercel, github pages ou outro da preferência). +O frontend da aplicação foi implantado na Vercel, garantindo fácil acesso e escalabilidade. A Vercel oferece integração contínua e deploy automático sempre que alterações são feitas no repositório. -## GIT -- Faça um fork deste repositório. -- Criar uma branch para codar as suas features. -- Criar um pull-request quando o teste for finalizado e submetido. +A aplicação pode ser acessada através do seguinte link: -## Contato técnico. -- Junior Schmidt -- GSS | Desenvolvedor Back-end & Tech Lead. -- schmidt@repenso.eco -- www.gss.eco +- [Clique aqui para ver em projeto em produção](https://frontend-test-tree-lyart.vercel.app/) diff --git a/app/_components/checkout-form.tsx b/app/_components/checkout-form.tsx new file mode 100644 index 0000000..07712d3 --- /dev/null +++ b/app/_components/checkout-form.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Loading } from "./loading"; +import { useForm } from "react-hook-form"; +import { + CheckoutSchema, + CheckoutSchemaType, +} from "../_schemas/checkout-schema"; +import { totalOfInstallments } from "../_utils/generate-installment-options"; +import { processPayment } from "../_data/process-payment"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@/components/ui/button"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, + Form, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { z } from "zod"; +interface CheckoutForm { + co2: number; + cred: number; +} + +export const CheckoutForm = ({ co2, cred }: CheckoutForm) => { + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(CheckoutSchema), + defaultValues: { + cardholder: { + identification: { + type: "CPF", + number: "", + }, + name: "", + phone: "", + }, + card_number: "", + expiration_month: "", + expiration_year: "", + security_code: "", + email: "", + paymentInstallments: "", + }, + + mode: "onChange", + criteriaMode: "all", + }); + + const { + formState: { isValid, isSubmitting }, + } = form; + + async function onSubmit(values: z.infer) { + const paymentData = { + co2: co2, + cred: cred, + card_number: values.card_number, + expiration_month: Number(values.expiration_month), + expiration_year: Number(values.expiration_year), + security_code: values.security_code, + cardholder: { + name: values.cardholder.name, + identification: { + type: values.cardholder.identification.type, + number: values.cardholder.identification.number, + }, + }, + }; + + try { + await processPayment(paymentData); + router.push("/checkout-success"); + } catch (error) { + console.log(error); + router.push("/checkout-error"); + } + } + + return ( +
+ + {isSubmitting && } + +
+ ( + + Nome + + + + + + )} + /> + +
+ ( + + Telefone + + + + + + )} + /> + + ( + + CPF + + + + + + )} + /> +
+ + ( + + Email + + + + + + )} + /> + + ( + + Número de Cartão + + + + + + )} + /> + +
+
+ ( + + + Data de Validade + + + + + + + )} + /> + + + / + + + ( + +
.
+ + + + +
+ )} + /> +
+ + ( + + CVC/CVV + + + + + + )} + /> +
+ + ( + + Opções de Parcelamento + + + + + + )} + /> +
+ +
+ + +
+ + + ); +}; diff --git a/app/_components/header.tsx b/app/_components/header.tsx new file mode 100644 index 0000000..a1a2a64 --- /dev/null +++ b/app/_components/header.tsx @@ -0,0 +1,15 @@ +import Image from "next/image"; +export const Header = () => { + return ( +
+
+
+ logo image +
+ + Checkout Carbon + +
+
+ ); +}; diff --git a/app/_components/loading.tsx b/app/_components/loading.tsx new file mode 100644 index 0000000..83ad02b --- /dev/null +++ b/app/_components/loading.tsx @@ -0,0 +1,16 @@ +import Image from "next/image"; + +export const Loading = () => { + return ( +
+
+ credit card front image +
+
+ ); +}; diff --git a/app/_constants/credit-icons.ts b/app/_constants/credit-icons.ts new file mode 100644 index 0000000..4dc7d38 --- /dev/null +++ b/app/_constants/credit-icons.ts @@ -0,0 +1,26 @@ +export const icon = [ + { + path: "/master-card-icon.svg", + alt: "Logo do Cartão Master Card", + with: 25, + height: 25, + }, + { + path: "/visa-icon.svg", + alt: "Logo do Cartão Visa", + with: 30, + height: 10, + }, + { + path: "/american-express-icon.svg", + alt: "Logo do Cartão American Express", + with: 42, + height: 15, + }, + { + path: "/elo-icon.svg", + alt: "Logo do Cartão Elo", + with: 54, + height: 20, + }, +]; diff --git a/app/_data/fetch-credit-price/index.ts b/app/_data/fetch-credit-price/index.ts new file mode 100644 index 0000000..f356863 --- /dev/null +++ b/app/_data/fetch-credit-price/index.ts @@ -0,0 +1,17 @@ +export const fetchCreditPrice = async (credId: number) => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API}/credit-price/${credId}`, + ); + + if (!response.ok) { + throw new Error("Erro ao buscar preço do crédito"); + } + + const data = await response.json(); + return data.amout; + } catch (error) { + console.error("Erro ao buscar preço do crédito:", error); + throw error; + } +}; diff --git a/app/_data/process-payment/index.ts b/app/_data/process-payment/index.ts new file mode 100644 index 0000000..b63eef2 --- /dev/null +++ b/app/_data/process-payment/index.ts @@ -0,0 +1,21 @@ +import { Payment } from "../../_types/payment"; +import { redirect } from "next/navigation"; +export const processPayment = async (data: Payment) => { + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API}/payment`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + redirect("/checkout-error"); + } + + const result = await response.json(); + return result; + } catch (error) { + console.error("Erro ao processar pagamento:", error); + throw error; + } +}; diff --git a/app/_schemas/checkout-schema.ts b/app/_schemas/checkout-schema.ts new file mode 100644 index 0000000..1284ead --- /dev/null +++ b/app/_schemas/checkout-schema.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +const CardholderSchema = z.object({ + name: z.string().min(1, "O nome do titular do cartão é obrigatório."), + identification: z.object({ + type: z.enum(["CPF"]), + number: z + .string() + .transform((val) => val.replace(/[^0-9]+/g, "")) + .refine((val) => val.length === 11, { + message: "O número do CPF deve conter 11 dígitos.", + }), + }), + phone: z.string().nonempty("Telefone é obrigatório"), +}); + +export const CheckoutSchema = z.object({ + card_number: z + .string() + .transform((val) => val.replace(/[^0-9]+/g, "")) + .refine((val) => val.length === 16, { + message: "O número do cartão deve conter 16 dígitos.", + }), + + expiration_month: z.string().regex(/^(0[1-9]|1[0-2])$/, "Mês inválido"), + expiration_year: z + .string() + .regex(/^\d{2}$/, "Formato inválido") + .refine( + (val) => { + const year = parseInt(val, 10); + const currentYear = new Date().getFullYear(); + const currentYearShort = currentYear.toString().slice(-2); // Pega os dois últimos dígitos do ano atual + return year >= parseInt(currentYearShort, 10); // Permite anos de 2023 em diante + }, + { + message: `Ano inválido, deve ser no mínimo ${new Date().getFullYear().toString().slice(-2)}`, // Pega os dois últimos dígitos do ano atual + }, + ) + .transform((val) => `20${val}`), + security_code: z + .string() + .regex(/^\d{3}$/, "O código de segurança deve conter 3 dígitos."), + cardholder: CardholderSchema, + email: z.string().email("E-mail inválido").min(1, "E-mail é obrigatório"), + paymentInstallments: z + .string() + .nonempty("Opção de parcelamento é obrigatória"), +}); + +export type CheckoutSchemaType = z.infer; diff --git a/app/_types/payment.ts b/app/_types/payment.ts new file mode 100644 index 0000000..2defb46 --- /dev/null +++ b/app/_types/payment.ts @@ -0,0 +1,17 @@ +interface Cardholder { + name: string; + identification: { + type: string; + number: string; + }; +} + +export interface Payment { + co2: number; + cred: number; + card_number: string; + expiration_month: string | number; + expiration_year: string | number; + security_code: string; + cardholder: Cardholder; +} diff --git a/app/_utils/generate-installment-options.tsx b/app/_utils/generate-installment-options.tsx new file mode 100644 index 0000000..1463828 --- /dev/null +++ b/app/_utils/generate-installment-options.tsx @@ -0,0 +1,24 @@ +export function totalOfInstallments( + value: number, + maxInstallments: number = 12, +): { value: string; label: string }[] { + if (maxInstallments < 1) { + throw new Error("O número de parcelas deve ser pelo menos 1."); + } + + const result = []; + + for (let i = 1; i <= maxInstallments; i++) { + const installmentValue = value / i; + const formattedValue = Number.isInteger(installmentValue) + ? installmentValue.toString() + : installmentValue.toFixed(2).replace(".", ","); + + result.push({ + value: i.toString(), + label: `${i}x R$ ${formattedValue}`, + }); + } + + return result; +} diff --git a/app/checkout-error/page.tsx b/app/checkout-error/page.tsx new file mode 100644 index 0000000..fd30735 --- /dev/null +++ b/app/checkout-error/page.tsx @@ -0,0 +1,61 @@ +import { Button } from "@/components/ui/button"; +import { Header } from "../_components/header"; +import Image from "next/image"; +import Link from "next/link"; +import { Roboto_Condensed } from "next/font/google"; + +const robotoCondensed = Roboto_Condensed({ + weight: ["500"], + subsets: ["latin-ext"], +}); +const CheckoutSuccessPage = () => { + return ( +
+
+
+
+

+ Desculpe +

+ +
+ Ocorreu um erro inesperado +
+

+

Verifique as informações do seu cartão

+

+ Se o problema persiste, contate-se com o seu {"\n"} banco +

+

+ +
+ + + +
+
+
+
+
+ credit card front image +
+
+
+
+
+ ); +}; + +export default CheckoutSuccessPage; diff --git a/app/checkout-success/page.tsx b/app/checkout-success/page.tsx new file mode 100644 index 0000000..65eec08 --- /dev/null +++ b/app/checkout-success/page.tsx @@ -0,0 +1,61 @@ +import { Button } from "@/components/ui/button"; +import { Header } from "../_components/header"; +import Image from "next/image"; +import { Roboto_Condensed } from "next/font/google"; + +const robotoCondensed = Roboto_Condensed({ + weight: ["500"], + subsets: ["latin-ext"], +}); +const CheckoutSuccessPage = () => { + return ( +
+
+
+
+

+ Parabéns! +

+ +
+ Você compensou suas emissões {"\n"}com sucesso! +
+

+

+ Com essa ação você contribui para um mundo mais {"\n"}sustentável +

+

+ Um email com o certificado de compensação será {"\n"}enviado para + você em breve +

+

+ +
+ +
+
+
+
+
+ credit card front image +
+
+
+
+
+ ); +}; + +export default CheckoutSuccessPage; diff --git a/app/checkout/layout.tsx b/app/checkout/layout.tsx new file mode 100644 index 0000000..9006ca1 --- /dev/null +++ b/app/checkout/layout.tsx @@ -0,0 +1,7 @@ +export default function CheckoutLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return
{children}
; +} diff --git a/app/checkout/page.tsx b/app/checkout/page.tsx new file mode 100644 index 0000000..0ef3fe8 --- /dev/null +++ b/app/checkout/page.tsx @@ -0,0 +1,101 @@ +"use client"; +import Image from "next/image"; +import { Header } from "../_components/header"; +import { CheckoutForm } from "../_components/checkout-form"; +import { icon } from "../_constants/credit-icons"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { fetchCreditPrice } from "../_data/fetch-credit-price"; + +export default function CheckoutPage() { + const searchParams = useSearchParams(); + const [amout, setAmout] = useState(null); + const [co2, setCo2] = useState(0); + const [cred, setCred] = useState(0); + useEffect(() => { + const co2Param = Number(searchParams.get("co2")) || 0; + const credParam = Number(searchParams.get("cred")) || 0; + + if (credParam) { + const fetchAndCalculate = async () => { + try { + const result = await fetchCreditPrice(credParam); + setAmout(result * co2Param); + setCo2(co2Param); + setCred(result * co2Param); + } catch (error) { + console.error("Erro ao calcular o valor:", error); + } + }; + + fetchAndCalculate(); + } + }, [searchParams]); + + return ( +
+
+
+
+ +
+ Logo DSS +

+ É garantida a confidencialidade, disponibilidade e integridade de + todos os processos seguindo as melhores práticas do mercado para + que você possa utilizar todos os produtos com segurança. +

+
+
+ {icon.map((i) => ( + {i.alt} + ))} +

e mais...

+
+
+
+
+
+ credit card front image +
+
+

+ Resumo de compra +

+
+

+ Resumo de compra +

+
+
+ summary image +
+
+

+ Compensação de emissões +

+ R$: {amout} +
+
+
+
+
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..64daa49 Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..2d6a385 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,96 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 179, 100%, 32%; + --primary-foreground: 179, 100%, 29%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + .font-roboto-300 { + font-family: var(--font-roboto); + font-weight: 300; + } + + .font-roboto-500 { + font-family: var(--font-roboto); + font-weight: 500; + } + + .font-roboto-700 { + font-family: var(--font-roboto); + font-weight: 700; + } + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 179, 100%, 32%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + .font-roboto-300 { + font-family: var(--font-roboto); + font-weight: 300; + } + + .font-roboto-500 { + font-family: var(--font-roboto); + font-weight: 500; + } + + .font-roboto-700 { + font-family: var(--font-roboto); + font-weight: 700; + } + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..058f2bf --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import { Roboto } from "next/font/google"; +import "./globals.css"; +import { Suspense } from "react"; +import { Loading } from "./_components/loading"; + +const roboto = Roboto({ + weight: ["700", "300", "500"], + variable: "--font-roboto", + subsets: ["latin-ext"], +}); + +export const metadata: Metadata = { + title: "Checkout Carbon", + description: "Checkout para compensação de emissões", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + }>{children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..b063520 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Loading } from "./_components/loading"; + +export default function Home() { + const { push } = useRouter(); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + + const timeout = setTimeout(() => { + push("/checkout?co2=1&cred=2"); + }, 1000); + + return () => clearTimeout(timeout); + }, [push]); + + if (loading) { + return ; + } + + return null; +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..dea737b --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..58a6685 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-white shadow hover:bg-primary-foreground", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input border-[#B0B0B0] text-[#B0B0B0] shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "w-[285px] lg:w-[197px] h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..1cc0c12 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,83 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..c3daa38 --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,179 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +