├── public ├── favicon.ico ├── assets │ ├── arcade.png │ ├── success.png │ ├── game-console.png │ ├── online-gaming.png │ └── screenshot-form.webp ├── vercel.svg ├── thirteen.svg └── next.svg ├── postcss.config.js ├── .vscode └── settings.json ├── styles └── globals.css ├── next.config.js ├── lib └── utils.ts ├── .eslintrc.json ├── app ├── head.tsx ├── layout.tsx └── page.tsx ├── pages └── api │ └── hello.ts ├── .gitignore ├── components ├── ui │ ├── label.tsx │ ├── separator.tsx │ ├── checkbox.tsx │ ├── input.tsx │ ├── switch.tsx │ ├── radio-group.tsx │ └── button.tsx ├── FormWrapper.tsx ├── SuccessMessage.tsx ├── AddonsForm.tsx ├── UserInfoForm.tsx ├── FinalStep.tsx ├── SideBar.tsx └── PlanForm.tsx ├── tsconfig.json ├── hooks └── useMultiplestepForm.ts ├── package.json ├── README.md └── tailwind.config.js /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marcosfitzsimons/multi-step-form/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/assets/arcade.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marcosfitzsimons/multi-step-form/HEAD/public/assets/arcade.png -------------------------------------------------------------------------------- /public/assets/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marcosfitzsimons/multi-step-form/HEAD/public/assets/success.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/assets/game-console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marcosfitzsimons/multi-step-form/HEAD/public/assets/game-console.png -------------------------------------------------------------------------------- /public/assets/online-gaming.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marcosfitzsimons/multi-step-form/HEAD/public/assets/online-gaming.png -------------------------------------------------------------------------------- /public/assets/screenshot-form.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marcosfitzsimons/multi-step-form/HEAD/public/assets/screenshot-form.webp -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | 4 | .border { 5 | background-clip: padding-box; 6 | } 7 | 8 | @tailwind utilities; 9 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | } 7 | 8 | module.exports = nextConfig 9 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "extends": ["next/core-web-vitals", ""], 4 | "plugins": ["tailwindcss"], 5 | "settings": { 6 | "tailwindcss": { 7 | "callees": ["cn"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/head.tsx: -------------------------------------------------------------------------------- 1 | export default function Head() { 2 | return ( 3 | <> 4 | Multi Step Form 5 | 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Label = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Label.displayName = LabelPrimitive.Root.displayName 22 | 23 | export { Label } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "baseUrl": ".", 23 | "paths": { 24 | "@/*": ["./*"] 25 | } 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Ubuntu } from "@next/font/google"; 2 | 3 | import "../styles/globals.css"; 4 | 5 | const ubuntu = Ubuntu({ 6 | variable: "--font-ubuntu", 7 | weight: ["400", "500", "700"], 8 | subsets: ["latin"], 9 | display: "swap", 10 | }); 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | return ( 18 | 22 | {/* 23 | will contain the components returned by the nearest parent 24 | head.tsx. Find out more at https://beta.nextjs.org/docs/api-reference/file-conventions/head 25 | */} 26 | 27 | 28 | {children} 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ); 29 | Separator.displayName = SeparatorPrimitive.Root.displayName; 30 | 31 | export { Separator }; 32 | -------------------------------------------------------------------------------- /hooks/useMultiplestepForm.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | 5 | export function useMultiplestepForm(steps: number) { 6 | const [currentStepIndex, setCurrentStepIndex] = useState(0) 7 | const [showSuccessMsg, setShowSuccessMsg] = useState(false); 8 | 9 | const nextStep = () => { 10 | if (currentStepIndex < steps - 1) { 11 | setCurrentStepIndex((i) => i + 1) 12 | } 13 | if (currentStepIndex === 3) { 14 | setShowSuccessMsg(true) 15 | } 16 | } 17 | 18 | const previousStep = () => { 19 | if (currentStepIndex > 0) { 20 | setCurrentStepIndex((i) => i - 1) 21 | } 22 | } 23 | 24 | const goTo = (index: number) => { 25 | setCurrentStepIndex(index) 26 | } 27 | 28 | return { 29 | currentStepIndex, 30 | steps, 31 | isFirstStep: currentStepIndex === 0, 32 | isLastStep: currentStepIndex === steps - 1, 33 | showSuccessMsg, 34 | goTo, 35 | nextStep, 36 | previousStep 37 | } 38 | } -------------------------------------------------------------------------------- /public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/FormWrapper.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import { motion } from "framer-motion"; 3 | 4 | type FormWrapperProps = { 5 | title: string; 6 | description: string; 7 | children: ReactNode; 8 | }; 9 | 10 | const formVariants = { 11 | hidden: { 12 | opacity: 0, 13 | x: -50, 14 | }, 15 | visible: { 16 | opacity: 1, 17 | x: 0, 18 | }, 19 | exit: { 20 | opacity: 0, 21 | x: 50, 22 | transition: { 23 | ease: "easeOut", 24 | }, 25 | }, 26 | }; 27 | 28 | const FormWrapper = ({ title, description, children }: FormWrapperProps) => { 29 | return ( 30 | 37 |
38 |

39 | {title} 40 |

41 |

{description}

42 |
43 | {children} 44 |
45 | ); 46 | }; 47 | 48 | export default FormWrapper; 49 | -------------------------------------------------------------------------------- /components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { Check } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |
12 |
16 | 24 |
25 |
26 | ); 27 | } 28 | ); 29 | Input.displayName = "Input"; 30 | 31 | export { Input }; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multi-step-form", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@next/font": "^13.1.6", 13 | "@radix-ui/react-checkbox": "^1.0.1", 14 | "@radix-ui/react-label": "^2.0.0", 15 | "@radix-ui/react-radio-group": "^1.1.1", 16 | "@radix-ui/react-separator": "^1.0.1", 17 | "@radix-ui/react-switch": "^1.0.1", 18 | "@radix-ui/react-toggle-group": "^1.0.2", 19 | "@types/node": "18.13.0", 20 | "@types/react": "18.0.28", 21 | "@types/react-dom": "18.0.10", 22 | "class-variance-authority": "^0.4.0", 23 | "clsx": "^1.2.1", 24 | "eslint-config-next": "13.1.6", 25 | "eslint-plugin-tailwindcss": "^3.8.3", 26 | "framer-motion": "^9.0.4", 27 | "lucide-react": "^0.112.0", 28 | "next": "13.1.6", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "react-rough-notation": "^1.0.3", 32 | "tailwind-merge": "^1.9.1", 33 | "tailwindcss-animate": "^1.0.5", 34 | "typescript": "4.9.5" 35 | }, 36 | "devDependencies": { 37 | "autoprefixer": "^10.4.13", 38 | "eslint": "^8.34.0", 39 | "postcss": "^8.4.21", 40 | "tailwindcss": "^3.2.6" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Multi-step form 2 | 3 | ## Table of contents 4 | 5 | - [Overview](#overview) 6 | - [Screenshot](#screenshot) 7 | - [Built with](#built-with) 8 | - [The challenge](#the-challenge) 9 | - [Links](#links) 10 | 11 | - [Author](#author) 12 | - [Acknowledgments](#acknowledgments) 13 | 14 | ## Overview 15 | 16 | ### Screenshot 17 | 18 | ![](./public/assets/screenshot-form.webp) 19 | 20 | ### Built with 21 | 22 | - Semantic HTML5 markup 23 | - Flexbox 24 | - Mobile-first workflow 25 | - [React](https://reactjs.org/) - JS library 26 | - [Next.js 13](https://beta.nextjs.org/docs) - React framework 27 | - [TailwindCSS](https://tailwindcss.com/) - For styles 28 | - [shadcn/ui](https://ui.shadcn.com/) - shadcn/ui components :rocket: 29 | - [Framer Motion](https://www.framer.com/motion/) - For animations 30 | 31 | ### The challenge 32 | 33 | Users should be able to: 34 | 35 | - Complete each step of the sequence 36 | - See a summary of their selections on the final step and confirm their order 37 | - View the optimal layout for the interface depending on their device's screen size 38 | - See hover and focus states for all interactive elements on the page 39 | 40 | ### Links 41 | 42 | - Live Site URL: [Here](https://multi-step-form-tawny.vercel.app/) 43 | 44 | ## Author 45 | 46 | - Website - [Marcos V Fitzsimons](https://marcosfitzsimons.com.ar/) 47 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )); 27 | Switch.displayName = SwitchPrimitives.Root.displayName; 28 | 29 | export { Switch }; 30 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class", '[data-theme="dark"]'], 4 | content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"], 5 | theme: { 6 | extend: { 7 | fontFamily: { 8 | sans: ['var(--font-ubuntu)'], 9 | }, 10 | keyframes: { 11 | "accordion-down": { 12 | from: { height: 0 }, 13 | to: { height: "var(--radix-accordion-content-height)" }, 14 | }, 15 | "accordion-up": { 16 | from: { height: "var(--radix-accordion-content-height)" }, 17 | to: { height: 0 }, 18 | }, 19 | }, 20 | animation: { 21 | "accordion-down": "accordion-down 0.2s ease-out", 22 | "accordion-up": "accordion-up 0.2s ease-out", 23 | }, 24 | colors: { 25 | neutral: { 26 | 750: '#313131' 27 | } 28 | }, 29 | boxShadow: { 30 | input: ` 31 | 0px 1px 0px -1px var(--tw-shadow-color), 32 | 0px 1px 1px -1px var(--tw-shadow-color), 33 | 0px 1px 2px -1px var(--tw-shadow-color), 34 | 0px 2px 4px -2px var(--tw-shadow-color), 35 | 0px 3px 6px -3px var(--tw-shadow-color) 36 | `, 37 | highlight: ` 38 | inset 0px 0px 0px 1px var(--tw-shadow-color), 39 | inset 0px 1px 0px var(--tw-shadow-color) 40 | `, 41 | }, 42 | }, 43 | }, 44 | plugins: [require("tailwindcss-animate")], 45 | } -------------------------------------------------------------------------------- /components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; 5 | import { Circle } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const RadioGroup = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => { 13 | return ( 14 | 19 | ); 20 | }); 21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; 22 | 23 | const RadioGroupItem = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => { 27 | return ( 28 | 36 | 37 | 38 | 39 | 40 | ); 41 | }); 42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; 43 | 44 | export { RadioGroup, RadioGroupItem }; 45 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { VariantProps, cva } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const buttonVariants = cva( 7 | "outline-none inline-flex items-center justify-center text-sm font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900", 12 | destructive: 13 | "bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600", 14 | outline: 15 | "bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100", 16 | subtle: 17 | "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100", 18 | ghost: 19 | "bg-transparent dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent", 20 | link: "bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent", 21 | }, 22 | size: { 23 | default: "h-10 py-2 px-4", 24 | sm: "h-9 px-2 rounded-md", 25 | lg: "h-11 px-8 rounded-md", 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: "default", 30 | size: "default", 31 | }, 32 | } 33 | ); 34 | 35 | export interface ButtonProps 36 | extends React.ButtonHTMLAttributes, 37 | VariantProps {} 38 | 39 | const Button = React.forwardRef( 40 | ({ className, variant, size, ...props }, ref) => { 41 | return ( 42 | 54 | 55 | 56 | 57 | ); 58 | }; 59 | 60 | export default SuccessMessage; 61 | -------------------------------------------------------------------------------- /components/AddonsForm.tsx: -------------------------------------------------------------------------------- 1 | import { FormItems } from "@/app/page"; 2 | import { Checkbox } from "@/components/ui/checkbox"; 3 | import FormWrapper from "./FormWrapper"; 4 | 5 | type stepProps = FormItems & { 6 | updateForm: (fieldToUpdate: Partial) => void; 7 | }; 8 | 9 | const AddonsForm = ({ addOns, yearly, updateForm }: stepProps) => { 10 | function handleCheckboxChange(addOnId: number, checked: boolean) { 11 | const updatedAddOns = addOns.map((addOn) => 12 | addOn.id === addOnId ? { ...addOn, checked } : addOn 13 | ); 14 | updateForm({ addOns: updatedAddOns }); 15 | } 16 | 17 | return ( 18 | 22 |
23 | {addOns.map((addOn) => ( 24 |
30 | 34 | handleCheckboxChange(addOn.id, checked as boolean) 35 | } 36 | /> 37 |
38 |
39 | 45 |

{addOn.subtitle}

46 |
47 |

48 | {`+$${yearly ? addOn.price * 10 : addOn.price}${ 49 | yearly ? "/yr" : "/mo" 50 | }`} 51 |

52 |
53 |
54 | ))} 55 |
56 |
57 | ); 58 | }; 59 | 60 | export default AddonsForm; 61 | -------------------------------------------------------------------------------- /components/UserInfoForm.tsx: -------------------------------------------------------------------------------- 1 | import FormWrapper from "./FormWrapper"; 2 | import { Input } from "@/components/ui/input"; 3 | import { Label } from "@/components/ui/label"; 4 | import { FormItems } from "../app/page"; 5 | 6 | type StepProps = FormItems & { 7 | updateForm: (fieldToUpdate: Partial) => void; 8 | errors: Partial; 9 | }; 10 | 11 | const UserInfoForm = ({ 12 | name, 13 | email, 14 | phone, 15 | errors, 16 | updateForm, 17 | }: StepProps) => { 18 | return ( 19 | 23 |
24 |
25 | 26 | updateForm({ name: e.target.value })} 34 | className="w-full" 35 | required 36 | /> 37 | {errors.name &&

{errors.name}

} 38 |
39 |
40 | 41 | updateForm({ email: e.target.value })} 49 | required 50 | /> 51 | {errors.email && ( 52 |

{errors.email}

53 | )} 54 |
55 |
56 | 57 | updateForm({ phone: e.target.value })} 65 | required 66 | /> 67 | {errors.phone && ( 68 |

{errors.phone}

69 | )} 70 |
71 |
72 |
73 | ); 74 | }; 75 | 76 | export default UserInfoForm; 77 | -------------------------------------------------------------------------------- /components/FinalStep.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | import FormWrapper from "./FormWrapper"; 5 | import { Separator } from "@/components/ui/separator"; 6 | import { FormItems } from "@/app/page"; 7 | 8 | type StepProps = FormItems & { 9 | goTo: (index: number) => void; 10 | }; 11 | 12 | const FinalStep = ({ yearly, plan, addOns, goTo }: StepProps) => { 13 | let planPrice = 0; 14 | switch (plan) { 15 | case "arcade": 16 | planPrice = 9; 17 | break; 18 | case "advanced": 19 | planPrice = 12; 20 | break; 21 | case "pro": 22 | planPrice = 15; 23 | break; 24 | default: 25 | planPrice = 0; 26 | break; 27 | } 28 | 29 | const filteredAddOns = addOns.filter((addOn) => addOn.checked === true); 30 | 31 | const totalAddOnsPrice = filteredAddOns?.reduce( 32 | (acc, obj) => acc + obj.price, 33 | 0 34 | ); 35 | console.log(totalAddOnsPrice); 36 | 37 | return ( 38 | 42 |
43 |
44 |
45 |
46 |

47 | {`${plan.charAt(0).toUpperCase() + plan.slice(1)} (${ 48 | yearly ? "Yearly" : "Monthly" 49 | })`} 50 |

51 | 57 |
58 |

{`$${ 59 | yearly ? planPrice * 10 : planPrice 60 | }${yearly ? "/yr" : "/mo"}`}

61 |
62 | {filteredAddOns.length > 0 && } 63 | {filteredAddOns?.map((addOn) => ( 64 |
68 |

{addOn.title}

69 |

{`$${yearly ? addOn.price * 10 : addOn.price}${ 70 | yearly ? "/yr" : "/mo" 71 | }`}

72 |
73 | ))} 74 |
75 |
76 |

77 | Total (per {yearly ? "year" : "month"}) 78 |

79 |

80 | +$ 81 | {yearly 82 | ? planPrice * 10 + totalAddOnsPrice * 10 83 | : planPrice + totalAddOnsPrice} 84 | /{yearly ? "yr" : "mo"} 85 |

86 |
87 |
88 |
89 | ); 90 | }; 91 | 92 | export default FinalStep; 93 | -------------------------------------------------------------------------------- /components/SideBar.tsx: -------------------------------------------------------------------------------- 1 | import { RoughNotation } from "react-rough-notation"; 2 | 3 | type NavProps = { 4 | currentStepIndex: number; 5 | goTo: (index: number) => void; 6 | }; 7 | 8 | const SideBar = ({ currentStepIndex, goTo }: NavProps) => { 9 | return ( 10 |
11 | 95 |
96 | ); 97 | }; 98 | 99 | export default SideBar; 100 | -------------------------------------------------------------------------------- /components/PlanForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import Image from "next/image"; 5 | import * as ToggleGroup from "@radix-ui/react-toggle-group"; 6 | import { Label } from "@/components/ui/label"; 7 | import { Switch } from "@/components/ui/switch"; 8 | import FormWrapper from "./FormWrapper"; 9 | import { FormItems } from "@/app/page"; 10 | import arcadeImg from "../public/assets/arcade.png"; 11 | import advancedImg from "../public/assets/game-console.png"; 12 | import proImg from "../public/assets/online-gaming.png"; 13 | 14 | type stepProps = FormItems & { 15 | updateForm: (fieldToUpdate: Partial) => void; 16 | }; 17 | 18 | type Plan = "arcade" | "advanced" | "pro"; 19 | 20 | const PlanForm = ({ updateForm, plan, yearly }: stepProps) => { 21 | const [yearlyUpdated, setYearlyUpdated] = useState(yearly); 22 | const [planSelected, setPlanSelected] = useState(plan); 23 | 24 | const handleCheckedChange = (yearlyUpdated: boolean) => { 25 | setYearlyUpdated((prev) => !prev); 26 | updateForm({ yearly: yearlyUpdated }); 27 | }; 28 | 29 | const handleValueChange = (planSelected: Plan) => { 30 | if (planSelected) { 31 | setPlanSelected(planSelected); 32 | updateForm({ plan: planSelected }); 33 | } 34 | }; 35 | 36 | return ( 37 | 41 | 48 | 52 | arcade 53 |
54 |

Arcade

55 |

{yearly ? "$90/yr" : "$9/mo"}

56 | {yearly && ( 57 | 2 months free 58 | )} 59 |
60 |
61 | 65 | advanced 66 |
67 |

Advanced

68 |

{yearly ? "$120/yr" : "$12/mo"}

69 | {yearly && ( 70 | 2 months free 71 | )} 72 |
73 |
74 | 75 | 79 | pro 80 |
81 |

Pro

82 |

{yearly ? "$150/yr" : "$15/mo"}

83 | {yearly && ( 84 | 2 months free 85 | )} 86 |
87 |
88 |
89 |
90 |
91 | 97 | 102 | 108 |
109 |
110 |
111 | ); 112 | }; 113 | 114 | export default PlanForm; 115 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { useMultiplestepForm } from "hooks/useMultiplestepForm"; 6 | import { AnimatePresence } from "framer-motion"; 7 | import UserInfoForm from "@/components/UserInfoForm"; 8 | import PlanForm from "@/components/PlanForm"; 9 | import AddonsForm from "@/components/AddonsForm"; 10 | import FinalStep from "@/components/FinalStep"; 11 | import SuccessMessage from "@/components/SuccessMessage"; 12 | import SideBar from "@/components/SideBar"; 13 | 14 | interface AddOn { 15 | id: number; 16 | checked: boolean; 17 | title: string; 18 | subtitle: string; 19 | price: number; 20 | } 21 | 22 | export type FormItems = { 23 | name: string; 24 | email: string; 25 | phone: string; 26 | plan: "arcade" | "advanced" | "pro"; 27 | yearly: boolean; 28 | addOns: AddOn[]; 29 | }; 30 | 31 | const initialValues: FormItems = { 32 | name: "", 33 | email: "", 34 | phone: "", 35 | plan: "arcade", 36 | yearly: false, 37 | addOns: [ 38 | { 39 | id: 1, 40 | checked: true, 41 | title: "Online Service", 42 | subtitle: "Access to multiple games", 43 | price: 1, 44 | }, 45 | { 46 | id: 2, 47 | checked: false, 48 | title: "Large storage", 49 | subtitle: "Extra 1TB of cloud save", 50 | price: 2, 51 | }, 52 | { 53 | id: 3, 54 | checked: false, 55 | title: "Customizable Profile", 56 | subtitle: "Custom theme on your profile", 57 | price: 2, 58 | }, 59 | ], 60 | }; 61 | 62 | export default function Home() { 63 | const [formData, setFormData] = useState(initialValues); 64 | const [errors, setErrors] = useState>({}); 65 | const { 66 | previousStep, 67 | nextStep, 68 | currentStepIndex, 69 | isFirstStep, 70 | isLastStep, 71 | steps, 72 | goTo, 73 | showSuccessMsg, 74 | } = useMultiplestepForm(4); 75 | 76 | function updateForm(fieldToUpdate: Partial) { 77 | const { name, email, phone } = fieldToUpdate; 78 | 79 | if (name && name.trim().length < 3) { 80 | setErrors((prevState) => ({ 81 | ...prevState, 82 | name: "Name should be at least 3 characters long", 83 | })); 84 | } else if (name && name.trim().length > 15) { 85 | setErrors((prevState) => ({ 86 | ...prevState, 87 | name: "Name should be no longer than 15 characters", 88 | })); 89 | } else { 90 | setErrors((prevState) => ({ 91 | ...prevState, 92 | name: "", 93 | })); 94 | } 95 | 96 | if (email && !/\S+@\S+\.\S+/.test(email)) { 97 | setErrors((prevState) => ({ 98 | ...prevState, 99 | email: "Please enter a valid email address", 100 | })); 101 | } else { 102 | setErrors((prevState) => ({ 103 | ...prevState, 104 | email: "", 105 | })); 106 | } 107 | 108 | if (phone && !/^[0-9]{10}$/.test(phone)) { 109 | setErrors((prevState) => ({ 110 | ...prevState, 111 | phone: "Please enter a valid 10-digit phone number", 112 | })); 113 | } else { 114 | setErrors((prevState) => ({ 115 | ...prevState, 116 | phone: "", 117 | })); 118 | } 119 | 120 | setFormData({ ...formData, ...fieldToUpdate }); 121 | } 122 | 123 | const handleOnSubmit = (e: React.FormEvent) => { 124 | e.preventDefault(); 125 | if (Object.values(errors).some((error) => error)) { 126 | return; 127 | } 128 | nextStep(); 129 | }; 130 | 131 | return ( 132 |
137 | {!showSuccessMsg ? ( 138 | 139 | ) : ( 140 | "" 141 | )} 142 |
145 | {showSuccessMsg ? ( 146 | 147 | 148 | 149 | ) : ( 150 |
154 | 155 | {currentStepIndex === 0 && ( 156 | 162 | )} 163 | {currentStepIndex === 1 && ( 164 | 165 | )} 166 | {currentStepIndex === 2 && ( 167 | 168 | )} 169 | {currentStepIndex === 3 && ( 170 | 171 | )} 172 | 173 |
174 |
175 | 187 |
188 |
189 |
190 | 196 |
197 |
198 |
199 |
200 | )} 201 |
202 |
203 | ); 204 | } 205 | --------------------------------------------------------------------------------