diff --git a/apps/www/components/buttons/AddAssetButton.tsx b/apps/www/components/buttons/AddAssetButton.tsx index e791e201..4ae6e52d 100644 --- a/apps/www/components/buttons/AddAssetButton.tsx +++ b/apps/www/components/buttons/AddAssetButton.tsx @@ -1,123 +1,25 @@ -import { - Bitcoin, - Building, - Car, - Folder, - GitGraph, - Hourglass, - MoreHorizontal, - Wallet, -} from "lucide-react"; +import type { FC, ReactNode } from "react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { Button } from "../ui/button"; +import { Dialog, DialogContent, DialogTrigger } from "../ui/dialog"; -import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; +interface FlowModalProps { + triggerLabel: string; + children: ReactNode; +} -export function AddAssetButton() { +export const AddAssetButton: FC = ({ + triggerLabel, + children, +}) => { return ( - + - - - Add new account - Add the account type you want. - - -
- - -
-
- - -
-
- - -
-
- -
- - -
-
- - -
-
- - -
-
- {/* - - */} + + {children}
); -} +}; diff --git a/apps/www/components/forms/account-form/car-fields.tsx b/apps/www/components/forms/account-form/car-fields.tsx new file mode 100644 index 00000000..6c011e03 --- /dev/null +++ b/apps/www/components/forms/account-form/car-fields.tsx @@ -0,0 +1,12 @@ +import { useFormContext } from "react-hook-form"; + +import { CommonAccountFields } from "./common-account-fields"; + +export const CarFormFields = () => { + const { control } = useFormContext(); + return ( + <> + + + ); +}; diff --git a/apps/www/components/forms/account-form/common-account-fields.tsx b/apps/www/components/forms/account-form/common-account-fields.tsx new file mode 100644 index 00000000..b13eea7b --- /dev/null +++ b/apps/www/components/forms/account-form/common-account-fields.tsx @@ -0,0 +1,98 @@ +import { format } from "date-fns"; +import { CalendarIcon } from "lucide-react"; +import { useFormContext } from "react-hook-form"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + FormControl, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +export const CommonAccountFields = () => { + const { control } = useFormContext(); + return ( + <> + {/* Purchase Date Field */} +
+ ( + + Purchase Date + + + + + + + + + + date > new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> +
+ {/* Purchase Value Field */} + ( + + Purchase Value + + + + + )} + /> + {/* Current Value Field */} + ( + + Current Value + + + + + )} + /> + + ); +}; diff --git a/apps/www/components/forms/account-form/crypto-fields.tsx b/apps/www/components/forms/account-form/crypto-fields.tsx new file mode 100644 index 00000000..31b168b8 --- /dev/null +++ b/apps/www/components/forms/account-form/crypto-fields.tsx @@ -0,0 +1,80 @@ +import { useFormContext } from "react-hook-form"; + +import { + FormControl, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +import { CommonAccountFields } from "./common-account-fields"; + +export const CryptoFormFields = () => { + const { control } = useFormContext(); + return ( + <> + {/* Currency Name Field */} +
+ ( + + Currency Name + + + + + )} + /> +
+ {/* Wallet Address */} +
+ ( + + Wallet Address + + + + + )} + /> +
+ {/* Conversion Rate (at Purchase) */} +
+ ( + + Conversion Rate + + + + + )} + /> +
+ {/* Current Conversion Rate */} +
+ ( + + Current Conversion Rate + + + + + )} + /> +
+ + + ); +}; diff --git a/apps/www/components/forms/account-form/index.tsx b/apps/www/components/forms/account-form/index.tsx new file mode 100644 index 00000000..e733ab35 --- /dev/null +++ b/apps/www/components/forms/account-form/index.tsx @@ -0,0 +1,45 @@ +import { type FC } from "react"; +import { type UseFormReturn } from "react-hook-form"; + +import { type FormFields } from "@/hooks/use-flow-modal-state"; +import { Form } from "@/components/ui/form"; +import { type AccountType } from "@/components/modals/add-asset-flow"; + +import { CarFormFields } from "./car-fields"; +import { CryptoFormFields } from "./crypto-fields"; +import { InputFormFields } from "./input-fields"; +import { InvestmentFormFields } from "./investment-fields"; +import { MiscFormFields } from "./misc-fields"; +import { RealEstateFormFields } from "./real-estate-fields"; + +const generateFormFields = (accountType: AccountType) => { + switch (accountType) { + case "real-estate": + return ; + case "crypto": + return ; + case "investment": + return ; + case "input": + return ; + case "car": + return ; + case "misc": + return ; + default: + return null; + } +}; + +interface AccountFormProps { + type: AccountType; + form: UseFormReturn; +} + +export const AccountForm: FC = ({ type, form }) => { + return ( +
+ {generateFormFields(type)}
+ + ); +}; diff --git a/apps/www/components/forms/account-form/input-fields.tsx b/apps/www/components/forms/account-form/input-fields.tsx new file mode 100644 index 00000000..f9b255b8 --- /dev/null +++ b/apps/www/components/forms/account-form/input-fields.tsx @@ -0,0 +1,12 @@ +import { useFormContext } from "react-hook-form"; + +import { CommonAccountFields } from "./common-account-fields"; + +export const InputFormFields = () => { + const { control } = useFormContext(); + return ( + <> + + + ); +}; diff --git a/apps/www/components/forms/account-form/investment-fields.tsx b/apps/www/components/forms/account-form/investment-fields.tsx new file mode 100644 index 00000000..431924b9 --- /dev/null +++ b/apps/www/components/forms/account-form/investment-fields.tsx @@ -0,0 +1,12 @@ +import { useFormContext } from "react-hook-form"; + +import { CommonAccountFields } from "./common-account-fields"; + +export const InvestmentFormFields = () => { + const { control } = useFormContext(); + return ( + <> + + + ); +}; diff --git a/apps/www/components/forms/account-form/misc-fields.tsx b/apps/www/components/forms/account-form/misc-fields.tsx new file mode 100644 index 00000000..9d6b822c --- /dev/null +++ b/apps/www/components/forms/account-form/misc-fields.tsx @@ -0,0 +1,12 @@ +import { useFormContext } from "react-hook-form"; + +import { CommonAccountFields } from "./common-account-fields"; + +export const MiscFormFields = () => { + const { control } = useFormContext(); + return ( + <> + + + ); +}; diff --git a/apps/www/components/forms/account-form/real-estate-fields.tsx b/apps/www/components/forms/account-form/real-estate-fields.tsx new file mode 100644 index 00000000..4084fc2d --- /dev/null +++ b/apps/www/components/forms/account-form/real-estate-fields.tsx @@ -0,0 +1,81 @@ +import { useFormContext } from "react-hook-form"; + +import { + FormControl, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +import { CommonAccountFields } from "./common-account-fields"; + +export const RealEstateFormFields = () => { + const { control } = useFormContext(); + return ( + <> + {/* Address Field */} +
+ ( + + Address + + + + + )} + /> +
+ {/* City Field */} +
+ ( + + City + + + + + )} + /> +
+ {/* State & Postal Code Fields */} +
+
+ ( + + State + + + + + )} + /> +
+
+ ( + + Postal Code + + + + + )} + /> +
+
+ + + ); +}; diff --git a/apps/www/components/modals/add-asset-flow/components/account-type-selection.tsx b/apps/www/components/modals/add-asset-flow/components/account-type-selection.tsx new file mode 100644 index 00000000..497fbb4d --- /dev/null +++ b/apps/www/components/modals/add-asset-flow/components/account-type-selection.tsx @@ -0,0 +1,105 @@ +import { type FC } from "react"; +import { + Bitcoin, + Building, + Car, + Folder, + GitGraph, + MoreHorizontal, +} from "lucide-react"; + +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; + +import { type AccountTypeInfo } from ".."; + +interface AccountTypeSelectionProps { + onSelectAccountType: (accountType: AccountTypeInfo) => void; +} + +const accountTypes: AccountTypeInfo[] = [ + { + title: "Real Estate", + description: "Add details about your real estate.", + type: "real-estate", + value: "real-estate", + label: "Real Estate", + Icon: Building, + }, + { + title: "Crypto", + description: "Add details about your crypto.", + type: "crypto", + value: "crypto", + label: "Crypto", + Icon: Bitcoin, + }, + { + title: "Investment", + description: "Add details about your investment.", + type: "investment", + value: "investment", + label: "Investment", + Icon: GitGraph, + }, + { + title: "Input", + description: "Add details about your input.", + type: "input", + value: "input", + label: "Input", + Icon: Folder, + }, + { + title: "Car", + description: "Add details about your car.", + type: "car", + value: "car", + label: "Car", + Icon: Car, + }, + { + title: "Misc", + description: "Add details about your misc.", + type: "misc", + value: "misc", + label: "Misc", + Icon: MoreHorizontal, + }, +]; + +export const AccountTypeSelection: FC = ({ + onSelectAccountType, +}) => { + const handleSelectAccountType = (selectedValue: string) => { + const selectedAccountType = accountTypes.find( + (type) => type.value === selectedValue, + ); + if (selectedAccountType) { + onSelectAccountType(selectedAccountType); + } + }; + return ( + handleSelectAccountType(accountType)} + > + {accountTypes.map((accountType) => ( +
+ + +
+ ))} +
+ ); +}; diff --git a/apps/www/components/modals/add-asset-flow/components/footer.tsx b/apps/www/components/modals/add-asset-flow/components/footer.tsx new file mode 100644 index 00000000..0915ed62 --- /dev/null +++ b/apps/www/components/modals/add-asset-flow/components/footer.tsx @@ -0,0 +1,16 @@ +import type { FC, ReactNode } from "react"; + +import { DialogFooter } from "@/components/ui/dialog"; + +interface StepFooterProps { + show: boolean; + children?: ReactNode; +} + +export const Footer: FC = ({ show, children }) => { + if (!show) { + return null; + } + + return {children}; +}; diff --git a/apps/www/components/modals/add-asset-flow/components/header-controls.tsx b/apps/www/components/modals/add-asset-flow/components/header-controls.tsx new file mode 100644 index 00000000..a3192c76 --- /dev/null +++ b/apps/www/components/modals/add-asset-flow/components/header-controls.tsx @@ -0,0 +1,37 @@ +import { type FC } from "react"; +import { ChevronLeft, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { DialogClose } from "@/components/ui/dialog"; + +interface HeaderControlsProps { + currentStepId: number; + goToPreviousStep: () => void; +} + +export const HeaderControls: FC = ({ + currentStepId, + goToPreviousStep, +}) => { + return ( +
+ {/* Previous button */} + {currentStepId > 0 && ( + + )} + {/* Close button */} + + + Close + +
+ ); +}; diff --git a/apps/www/components/modals/add-asset-flow/index.tsx b/apps/www/components/modals/add-asset-flow/index.tsx new file mode 100644 index 00000000..3ca68e2a --- /dev/null +++ b/apps/www/components/modals/add-asset-flow/index.tsx @@ -0,0 +1,116 @@ +import { useCallback, useMemo } from "react"; +import { AnimatePresence, motion } from "framer-motion"; + +import { FlowStep, useFlowControl } from "@/hooks/use-flow-control"; +import { useFlowModalState } from "@/hooks/use-flow-modal-state"; +import { Button } from "@/components/ui/button"; +import { + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { AccountForm } from "@/components/forms/account-form"; + +import { AccountTypeSelection } from "./components/account-type-selection"; +import { Footer } from "./components/footer"; +import { HeaderControls } from "./components/header-controls"; + +export type AccountType = + | "real-estate" + | "crypto" + | "investment" + | "input" + | "car" + | "misc"; + +export interface AccountTypeInfo { + type: AccountType; + title: string; + description: string; + value: string; + label: string; + Icon: React.ElementType; +} + +export const AddAssetFlow = () => { + const { setAccountTypeInfo, accountTypeInfo, form } = useFlowModalState(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + const handleSelectAccountType = useCallback( + (selectedAccountType: AccountTypeInfo) => { + setAccountTypeInfo(selectedAccountType); + goToNextStep(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const accountFormTitle = accountTypeInfo + ? `Add ${accountTypeInfo.title}` + : "Step 2"; + const accountFormDescription = accountTypeInfo + ? accountTypeInfo.description + : "Description of Step 2"; + + // biome-ignore lint/correctness/useExhaustiveDependencies: + const steps = useMemo( + () => [ + { + id: 0, + title: "Add new account", + description: "Add the account type you want.", + component: ( + + ), + }, + { + id: 1, + title: accountFormTitle, + description: accountFormDescription, + component: accountTypeInfo ? ( + + ) : null, + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [handleSelectAccountType, accountFormTitle, accountFormDescription], + ); + + const { currentStepId, goToNextStep, goToPreviousStep } = useFlowControl({ + steps, + }); + + const currentStep = useMemo( + () => steps.find((step) => step.id === currentStepId), + [steps, currentStepId], + ); + + return ( + <> + { + goToPreviousStep(); + }} + /> + + {currentStep?.title} + {currentStep?.description} + + + + {currentStep?.component} +
+ +
+
+
+ + ); +}; diff --git a/apps/www/components/new-dashboard/components/dashboard-1.tsx b/apps/www/components/new-dashboard/components/dashboard-1.tsx index 4aa23f8b..731dc361 100644 --- a/apps/www/components/new-dashboard/components/dashboard-1.tsx +++ b/apps/www/components/new-dashboard/components/dashboard-1.tsx @@ -21,7 +21,6 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; import { ResizableHandle, ResizablePanel, @@ -31,7 +30,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AddAssetButton } from "@/components/buttons/AddAssetButton"; -import { AddRealEstateButton } from "@/components/buttons/AddRealEstateButton"; +import { AddAssetFlow } from "@/components/modals/add-asset-flow"; import { WorkspaceSwitcher } from "@/app/(dashboard)/dashboard/_components/workspace-switcher"; import { Mail } from "../data"; @@ -264,8 +263,9 @@ export function Dashboard({

Dashboard

- - + + +
diff --git a/apps/www/components/ui/dialog.tsx b/apps/www/components/ui/dialog.tsx index 55952716..0bc888f1 100644 --- a/apps/www/components/ui/dialog.tsx +++ b/apps/www/components/ui/dialog.tsx @@ -4,6 +4,8 @@ import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { X } from "lucide-react"; + + import { cn } from "@/lib/utils"; const Dialog = DialogPrimitive.Root; @@ -31,8 +33,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + showCloseButton?: boolean; + } +>(({ className, children, showCloseButton = true, ...props }, ref) => ( {children} - - - Close - + {showCloseButton && ( + + + Close + + )} )); diff --git a/apps/www/hooks/use-flow-control.ts b/apps/www/hooks/use-flow-control.ts new file mode 100644 index 00000000..f144b081 --- /dev/null +++ b/apps/www/hooks/use-flow-control.ts @@ -0,0 +1,81 @@ +"use client"; + +import { useCallback, useState, useEffect, type ReactNode } from "react"; + +export interface FlowStep { + id: number; + title?: string; + description?: string; + component: ReactNode; +} + +export interface UseFlowControlProps { + // steps is an array of FlowStep objects + steps: FlowStep[]; + // initialStep is the id of the first step + initialStep?: number; + // onStepChange (event) is a function that is called when the current step changes + onStepChange?: (newStepId: number) => void; + // onFlowComplete (event) is a function that is called when the flow is complete + onFlowComplete?: () => void; +} + +export function useFlowControl({ + steps, + initialStep = 0, + onStepChange, + onFlowComplete, +}: UseFlowControlProps) { + const [currentStepId, setCurrentStepId] = useState(initialStep); + const [isFlowComplete, setIsFlowComplete] = useState(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + const goToNextStep = useCallback(() => { + const nextStepIndex = currentStepId + 1; + const nextStepId = steps[nextStepIndex]?.id; + if (nextStepIndex < steps.length && nextStepId !== undefined) { + setCurrentStepId(nextStepId); + onStepChange?.(nextStepId); + } + }, [steps, currentStepId, onStepChange]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + const goToPreviousStep = useCallback(() => { + const prevStepIndex = currentStepId - 1; + const prevStepId = steps[prevStepIndex]?.id; + if (prevStepIndex >= 0 && prevStepId !== undefined) { + setCurrentStepId(prevStepId); + onStepChange?.(prevStepId); + } + }, [steps, currentStepId, onStepChange]); + + const setCurrentStep = (stepId: number) => { + setCurrentStepId(stepId); + onStepChange?.(stepId); + }; + + const resetFlow = () => { + setCurrentStepId(initialStep); + setIsFlowComplete(false); + }; + + const completeFlow = () => setIsFlowComplete(true); + + useEffect(() => { + if (isFlowComplete) { + onFlowComplete?.(); + } + }, [isFlowComplete, onFlowComplete]); + + return { + currentStepId, + isFlowComplete, + goToNextStep, + goToPreviousStep, + setCurrentStep, + resetFlow, + completeFlow, + }; +} + +export type UseFlowControlReturn = ReturnType; diff --git a/apps/www/hooks/use-flow-modal-state.ts b/apps/www/hooks/use-flow-modal-state.ts new file mode 100644 index 00000000..3ed16b79 --- /dev/null +++ b/apps/www/hooks/use-flow-modal-state.ts @@ -0,0 +1,59 @@ +"use client"; + +import { AccountType, AccountTypeInfo } from '@/components/modals/add-asset-flow'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const baseSchema = z.object({ + purchaseDate: z.date(), + purchaseValue: z.string(), + currentValue: z.string(), +}); + +export type BaseFormFields = z.infer; + +const realEstateSchema = baseSchema.extend({ + address: z.string(), + city: z.string(), + state: z.string(), + postalCode: z.string(), +}); + +export type RealEstateFormFields = z.infer; + +export type FormFields = BaseFormFields | RealEstateFormFields; + +const generateFormSchema = (accountType?: AccountType) => { + switch (accountType) { + case 'real-estate': + return realEstateSchema; + default: + return baseSchema; + } +}; + +export function useFlowModalState() { + const [accountTypeInfo, setAccountTypeInfo] = useState(); + const currentFormSchema = generateFormSchema(accountTypeInfo?.type); + const form = useForm>({ + resolver: zodResolver(currentFormSchema), + defaultValues: { + purchaseDate: new Date(), + purchaseValue: "", + currentValue: "", + } + }); + + const submitFlowData = () => { + console.log('Submitting flow data'); + }; + + return { + form, + submitFlowData, + accountTypeInfo, + setAccountTypeInfo, + } +} diff --git a/apps/www/package.json b/apps/www/package.json index d44ffe07..7e528a35 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -82,6 +82,7 @@ "date-fns": "^2.30.0", "dinero.js": "2.0.0-alpha.14", "dotenv-cli": "^7.3.0", + "framer-motion": "^11.0.8", "jotai": "^2.6.1", "lucide-react": "^0.314.0", "ms": "^2.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 249ea599..dcc11913 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: dotenv-cli: specifier: ^7.3.0 version: 7.3.0 + framer-motion: + specifier: ^11.0.8 + version: 11.0.8(react-dom@18.2.0)(react@18.2.0) jotai: specifier: ^2.6.1 version: 2.6.3(@types/react@18.2.33)(react@18.2.0) @@ -8791,6 +8794,24 @@ packages: '@emotion/is-prop-valid': 0.8.8 dev: false + /framer-motion@11.0.8(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-1KSGNuqe1qZkS/SWQlDnqK2VCVzRVEoval379j0FiUBJAZoqgwyvqFkfvJbgW2IPFo4wX16K+M0k5jO23lCIjA==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tslib: 2.6.2 + optionalDependencies: + '@emotion/is-prop-valid': 0.8.8 + dev: false + /fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} dev: false