diff --git a/src/components/Atoms/Controls/Input/Input.types.ts b/src/components/Atoms/Controls/Input/Input.types.ts new file mode 100644 index 0000000..020d334 --- /dev/null +++ b/src/components/Atoms/Controls/Input/Input.types.ts @@ -0,0 +1,79 @@ +import { MenuItem } from "@/types"; +import { ChangeHandler, FieldErrors, RegisterOptions } from "react-hook-form"; + +export interface CustomChangeEvent { + target: { + name: string; + value: any; + type: string; + }; +} + +export interface InputProps { + onChange: ChangeHandler; + onBlur: ChangeHandler; + name: string; + min?: string | number; + max?: string | number; + maxLength?: number; + minLength?: number; + pattern?: string; + required?: boolean; + disabled?: boolean; +} + +export interface CustomInputProps + extends Omit, "onChange"> { + name: string; + label?: string; + tooltip?: string; + type: + | "text" + | "email" + | "password" + | "number" + | "tel" + | "radio" + | "switch" + | "checkbox" + | "dropdown"; + radioOptions?: Array<{ label: string; value: string; disabled?: boolean }>; + required?: boolean; + badge?: string; + dropdownOptions?: Array; + customValidation?: RegisterOptions; + onChange?: ( + event: React.ChangeEvent | CustomChangeEvent, + ) => void; +} + +export interface RenderFieldProps { + type: + | "text" + | "email" + | "password" + | "number" + | "tel" + | "radio" + | "switch" + | "checkbox" + | "dropdown"; + disabled?: boolean | undefined; + radioOptions?: Array<{ label: string; value: string; disabled?: boolean }>; + dropdownOptions?: Array; + fieldValue: any; + createCustomEvent: any; + defaultValue?: any; + placeholder?: string; + className?: string; + errors: FieldErrors; + inputProps: InputProps; + inputRef: (el: HTMLInputElement | null) => void; + handleChange: ( + event: React.ChangeEvent | CustomChangeEvent, + ) => void; + name: string; + label: string | undefined; + ref: React.ForwardedRef; + props?: any; +} diff --git a/src/components/Atoms/Controls/Input/components/Badge.tsx b/src/components/Atoms/Controls/Input/components/Badge.tsx new file mode 100644 index 0000000..da62332 --- /dev/null +++ b/src/components/Atoms/Controls/Input/components/Badge.tsx @@ -0,0 +1,13 @@ +interface IBadge { + badge: string; +} + +const Badge = ({ badge }: IBadge) => { + return ( +
+ {badge} +
+ ); +}; + +export default Badge; diff --git a/src/components/Atoms/Controls/Input/components/FieldError.tsx b/src/components/Atoms/Controls/Input/components/FieldError.tsx new file mode 100644 index 0000000..465a9a5 --- /dev/null +++ b/src/components/Atoms/Controls/Input/components/FieldError.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +interface FieldErrorProps { + error?: { message?: string }; + disabled?: boolean; +} + +const FieldError: React.FC = ({ error, disabled }) => { + if (disabled || !error?.message) return null; + + return {error.message}; +}; + +export default FieldError; diff --git a/src/components/Atoms/Controls/Input/components/Label.tsx b/src/components/Atoms/Controls/Input/components/Label.tsx new file mode 100644 index 0000000..46ecf03 --- /dev/null +++ b/src/components/Atoms/Controls/Input/components/Label.tsx @@ -0,0 +1,25 @@ +interface LabelProps { + type: + | "text" + | "email" + | "password" + | "number" + | "tel" + | "radio" + | "switch" + | "checkbox" + | "dropdown"; + label: string; + name: string; +} + +const Label = ({ type, label, name }: LabelProps) => { + if (type === "switch" || type === "radio") return null; + return ( + + ); +}; + +export default Label; diff --git a/src/components/Atoms/Controls/Input/components/Tooltip.tsx b/src/components/Atoms/Controls/Input/components/Tooltip.tsx new file mode 100644 index 0000000..bdd416c --- /dev/null +++ b/src/components/Atoms/Controls/Input/components/Tooltip.tsx @@ -0,0 +1,18 @@ +import { Tooltip } from "@/components/Atoms/Misc/Tooltip"; +import { AiOutlineInfoCircle } from "react-icons/ai"; + +interface ITooltip { + text: string; +} + +const InfoTooltip = ({ text }: ITooltip) => { + return ( + +
+ +
+
+ ); +}; + +export default InfoTooltip; diff --git a/src/components/Atoms/Controls/Input/components/renderFieldForInput.tsx b/src/components/Atoms/Controls/Input/components/renderFieldForInput.tsx new file mode 100644 index 0000000..76c9a0b --- /dev/null +++ b/src/components/Atoms/Controls/Input/components/renderFieldForInput.tsx @@ -0,0 +1,22 @@ +import Input from "../formControls/input"; +import DropdownField from "../formControls/dropdown"; +import SwitchField from "../formControls/switch"; +import RadioButton from "../formControls/radio_buttons"; +import { RenderFieldProps } from "../Input.types"; + +export const renderFieldForInput = ({ ...props }: RenderFieldProps) => { + switch (props.type) { + case "radio": + return ; + + case "switch": + return ; + + case "dropdown": + return ; + + default: { + return ; + } + } +}; diff --git a/src/components/Atoms/Controls/Input/formControls/dropdown.tsx b/src/components/Atoms/Controls/Input/formControls/dropdown.tsx new file mode 100644 index 0000000..604aec0 --- /dev/null +++ b/src/components/Atoms/Controls/Input/formControls/dropdown.tsx @@ -0,0 +1,27 @@ +import DropdownMenu from "@/components/Molecules/Dropdowns"; +import { RenderFieldProps } from "../components/renderFieldForInput"; + +const DropdownField = ({ + disabled, + dropdownOptions, + fieldValue, + createCustomEvent, + defaultValue, + placeholder, + className, + handleChange, +}: RenderFieldProps) => { + return ( + handleChange(createCustomEvent(value.label))} + className={className} + > + {fieldValue || defaultValue || placeholder} + + ); +}; + +export default DropdownField; diff --git a/src/components/Atoms/Controls/Input/formControls/input.tsx b/src/components/Atoms/Controls/Input/formControls/input.tsx new file mode 100644 index 0000000..511c8ab --- /dev/null +++ b/src/components/Atoms/Controls/Input/formControls/input.tsx @@ -0,0 +1,58 @@ +import { tv } from "tailwind-variants"; +import { RenderFieldProps } from "../Input.types"; +import { usePasswordToggle } from "@/hooks/usePasswordToggle"; + +const inputClass = tv({ + base: "body-3 w-full rounded-xl p-4 bg-white bg-opacity-10 ring-0 outline-0 focus:ring-1 active:ring-1 focus:ring-white active:ring-white transition-all ease-in-out duration-300 text-white", + variants: { + error: { + true: "border border-error-600", + false: "", + }, + withIcon: { + true: "pr-12", // Add padding to the right to accommodate the icon + }, + }, +}); + +const Input = ({ + type, + disabled, + className, + errors, + inputProps, + inputRef, + handleChange, + defaultValue, + name, + ref, + props, +}: RenderFieldProps) => { + const { showPassword, PasswordToggleIcon } = usePasswordToggle(); + console.log("type", type); + return ( +
+ { + if (typeof inputRef === "function") inputRef(e); + if (typeof ref === "function") ref(e); + }} + /> + {type === "password" && PasswordToggleIcon} +
+ ); +}; + +export default Input; diff --git a/src/components/Atoms/Controls/Input/formControls/radio_buttons.tsx b/src/components/Atoms/Controls/Input/formControls/radio_buttons.tsx new file mode 100644 index 0000000..9ebba4a --- /dev/null +++ b/src/components/Atoms/Controls/Input/formControls/radio_buttons.tsx @@ -0,0 +1,31 @@ +import Radio from "../../RadioButton"; +import { RenderFieldProps } from "../components/renderFieldForInput"; + +const RadioButton = ({ + type, + disabled, + radioOptions, + defaultValue, + handleChange, + name, +}: RenderFieldProps) => { + return ( + ({ + ...opt, + disabled: opt.disabled || disabled, + })) || [] + } + defaultValue={String(defaultValue)} + className="space-y-2" + labelClassName="body-2 text-white ml-3" + onClick={(opt: { value: any }) => + handleChange({ target: { name, value: opt.value, type } }) + } + size="sm" + /> + ); +}; + +export default RadioButton; diff --git a/src/components/Atoms/Controls/Input/formControls/switch.tsx b/src/components/Atoms/Controls/Input/formControls/switch.tsx new file mode 100644 index 0000000..eb56ba5 --- /dev/null +++ b/src/components/Atoms/Controls/Input/formControls/switch.tsx @@ -0,0 +1,34 @@ +import Switch from "../../Switch"; +import { RenderFieldProps } from "../components/renderFieldForInput"; + +const SwitchField = ({ + type, + disabled, + className, + inputRef, + handleChange, + name, + label, + ref, + props, +}: RenderFieldProps) => { + return ( + { + if (typeof inputRef === "function") inputRef(e); + if (typeof ref === "function") ref(e); + else if (ref) ref.current = e; + }} + onChange={(checked: unknown) => + handleChange({ target: { name, value: checked, type } }) + } + /> + ); +}; + +export default SwitchField; diff --git a/src/components/Atoms/Controls/Input/index.tsx b/src/components/Atoms/Controls/Input/index.tsx index 6d72f67..bb958ca 100644 --- a/src/components/Atoms/Controls/Input/index.tsx +++ b/src/components/Atoms/Controls/Input/index.tsx @@ -1,60 +1,12 @@ -import * as React from "react"; -import { useFormContext, RegisterOptions, useWatch } from "react-hook-form"; -import { tv } from "tailwind-variants"; -import Switch from "@/components/Atoms/Controls/Switch"; -import DropdownMenu from "@/components/Molecules/Dropdowns"; -import Radio from "../RadioButton"; -import { MenuItem } from "@/types"; -import { IoIosEyeOff } from "react-icons/io"; -import { IoEye } from "react-icons/io5"; -import { Tooltip } from "../../Misc/Tooltip"; -import { AiOutlineInfoCircle } from "react-icons/ai"; - -interface CustomChangeEvent { - target: { - name: string; - value: any; - type: string; - }; -} - -interface CustomInputProps - extends Omit, "onChange"> { - name: string; - label?: string; - tooltip?: string; - type: - | "text" - | "email" - | "password" - | "number" - | "tel" - | "radio" - | "switch" - | "checkbox" - | "dropdown"; - radioOptions?: Array<{ label: string; value: string; disabled?: boolean }>; - required?: boolean; - badge?: string; - dropdownOptions?: Array; - customValidation?: RegisterOptions; - onChange?: ( - event: React.ChangeEvent | CustomChangeEvent, - ) => void; -} - -const inputClass = tv({ - base: "body-3 w-full rounded-xl p-4 bg-white bg-opacity-10 ring-0 outline-0 focus:ring-1 active:ring-1 focus:ring-white active:ring-white transition-all ease-in-out duration-300 text-white", - variants: { - error: { - true: "border border-error-600", - false: "", - }, - withIcon: { - true: "pr-12", // Add padding to the right to accommodate the icon - }, - }, -}); +import { getValidationRules } from "@/utils/getValidationRules"; +import { renderFieldForInput } from "./components/renderFieldForInput"; +import Label from "./components/Label"; +import Badge from "./components/Badge"; +import InfoTooltip from "./components/Tooltip"; +import { CustomChangeEvent, CustomInputProps } from "./Input.types"; +import React from "react"; +import FieldError from "./components/FieldError"; +import { useFormContext, useWatch } from "react-hook-form"; const Input = React.forwardRef( ( @@ -90,66 +42,19 @@ const Input = React.forwardRef( defaultValue: props.defaultValue, }); - const [showPassword, setShowPassword] = React.useState(false); - - const getValidationRules = (): RegisterOptions => { - let rules: RegisterOptions = { - required: - type !== "switch" && required - ? `${label || name} is required` - : false, - }; - - switch (type) { - case "email": - rules.pattern = { - value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, - message: "Invalid email address", - }; - break; - case "tel": - rules.pattern = { - value: /^[0-9]{10}$/, - message: "Invalid phone number (10 digits required)", - }; - break; - case "number": - rules.valueAsNumber = true; - rules.min = { value: 0, message: "Value must be positive" }; - break; - case "password": - rules.minLength = { - value: 8, - message: "Password must be at least 8 characters long", - }; - rules.validate = (value) => { - const hasUpperCase = /[A-Z]/.test(value); - const hasLowerCase = /[a-z]/.test(value); - const hasNumbers = /\d/.test(value); - const hasNonalphas = /\W/.test(value); - if ( - !hasUpperCase || - !hasLowerCase || - !hasNumbers || - !hasNonalphas - ) { - return "Password must contain an uppercase letter, lowercase letter, number, and special character"; - } - return true; - }; - break; - } - - if (customValidation) rules = { ...customValidation }; - - return rules; - }; - const { ref: inputRef, ...inputProps } = register( name, - getValidationRules(), + getValidationRules({ name, type, label, required, customValidation }), ); + const createCustomEvent = (value: any): CustomChangeEvent => ({ + target: { + name, + value, + type, + }, + }); + const handleChange = ( event: React.ChangeEvent | CustomChangeEvent, ) => { @@ -167,151 +72,43 @@ const Input = React.forwardRef( trigger(name); }; - const createCustomEvent = (value: any): CustomChangeEvent => ({ - target: { - name, - value, - type, - }, - }); - - const renderInput = () => { - switch (type) { - case "radio": - return ( - ({ - ...option, - disabled: option.disabled || disabled, - })) || [] - } - defaultValue={String(props.defaultValue)} - className="space-y-2" - labelClassName="body-2 text-white ml-3" - onClick={(selectedOption) => - handleChange(createCustomEvent(selectedOption.value)) - } - size="sm" - /> - ); - case "switch": - return ( - { - if (e) { - inputRef(e); - if (typeof ref === "function") ref(e); - else if (ref) ref.current = e; - } - }} - onChange={(checked) => handleChange(createCustomEvent(checked))} - /> - ); - case "dropdown": - return ( - handleChange(createCustomEvent(value.label))} - className={className} - > - {fieldValue - ? fieldValue - : (props.defaultValue ?? props.placeholder)} - - ); - default: - return ( -
- { - inputRef(e); - if (typeof ref === "function") ref(e); - }} - /> - {type === "password" && ( - - )} -
- ); - } - }; - React.useEffect(() => { - renderInput(); - if (!disabled) resetField(name, { defaultValue: props.defaultValue }); + if (!disabled) { + resetField(name, { defaultValue: props.defaultValue }); + } }, [disabled]); - const renderLabel = () => { - if (type === "switch" || type === "radio") return null; - return ( - - ); - }; - - const renderTooltip = (text: string) => { - if (!tooltip) return null; - return ( - -
- -
-
- ); - }; return (
- {label && renderLabel()} - {badge && ( -
- {badge} -
- )} - {tooltip && renderTooltip(tooltip)} + {label &&
- {renderInput()} - {!disabled && errors[name] && ( - - {errors[name]?.message as string} - - )} + + {renderFieldForInput({ + type, + disabled, + radioOptions, + dropdownOptions, + fieldValue, + defaultValue: props.defaultValue, + placeholder: props.placeholder, + className, + createCustomEvent, + errors, + inputProps, + inputRef, + handleChange, + name, + label, + ref, + props, + })} + +
); }, diff --git a/src/components/Molecules/Chatbot/Chatbot.stories.tsx b/src/components/Molecules/Chatbot/Chatbot.stories.tsx new file mode 100644 index 0000000..8118f5c --- /dev/null +++ b/src/components/Molecules/Chatbot/Chatbot.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import FloatingChatbotWidget from "./index"; +import { FloatingChatbotWidgetProps } from "./Chatbot.types"; + +const meta: Meta = { + title: "Molecules/FloatingChatbotWidget", + component: FloatingChatbotWidget, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + layout: "fullscreen", + docs: { + description: { + component: ` +# FloatingChatbotWidget 🤖 + +An animated, floating chatbot component that appears at the bottom right of the screen. +Perfect for Q&A bots, feedback collection, or storytelling interfaces. + +## Features + +- Framer Motion animations +- Predefined questions +- Message input with keyboard support +- Auto-responses for simple queries +- Click outside to close support +- Dark themed UI + +## Usage + +\`\`\`tsx + +\`\`\` + +You can also pass predefined questions: + +\`\`\`tsx + +\`\`\` + `, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Yeti Assistant 🤖", + predefinedQuestions: ["Tell me a story", "Hello", "Help", "Bye"], + initialMessages: [ + { + text: "Hi there! Ask me anything or choose a question below.", + isUser: false, + }, + ], + } satisfies FloatingChatbotWidgetProps, +}; diff --git a/src/components/Molecules/Chatbot/Chatbot.types.ts b/src/components/Molecules/Chatbot/Chatbot.types.ts new file mode 100644 index 0000000..4b78ba8 --- /dev/null +++ b/src/components/Molecules/Chatbot/Chatbot.types.ts @@ -0,0 +1,11 @@ +export interface ChatMessage { + message?: string; + text?: string; + isUser?: boolean; +} + +export interface FloatingChatbotWidgetProps { + initialMessages?: ChatMessage[]; + predefinedQuestions?: string[]; + title?: string; +} diff --git a/src/components/Molecules/Chatbot/components/ChatBubble.tsx b/src/components/Molecules/Chatbot/components/ChatBubble.tsx new file mode 100644 index 0000000..b1b10f2 --- /dev/null +++ b/src/components/Molecules/Chatbot/components/ChatBubble.tsx @@ -0,0 +1,13 @@ +import { ChatMessage } from "../Chatbot.types"; + +export const ChatBubble: React.FC = ({ text, isUser }) => ( +
+ {text} +
+); diff --git a/src/components/Molecules/Chatbot/components/ChatHeader.tsx b/src/components/Molecules/Chatbot/components/ChatHeader.tsx new file mode 100644 index 0000000..47a10dc --- /dev/null +++ b/src/components/Molecules/Chatbot/components/ChatHeader.tsx @@ -0,0 +1,13 @@ +import { FiX } from "react-icons/fi"; + +export const ChatHeader: React.FC<{ title: string; onClose: () => void }> = ({ + title, + onClose, +}) => ( +
+

{title}

+ +
+); diff --git a/src/components/Molecules/Chatbot/components/ChatInput.tsx b/src/components/Molecules/Chatbot/components/ChatInput.tsx new file mode 100644 index 0000000..f817565 --- /dev/null +++ b/src/components/Molecules/Chatbot/components/ChatInput.tsx @@ -0,0 +1,23 @@ +import { FiSend } from "react-icons/fi"; + +export const ChatInput: React.FC<{ + input: string; + setInput: (val: string) => void; + onSend: () => void; +}> = ({ input, setInput, onSend }) => ( +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onSend()} + /> + +
+); diff --git a/src/components/Molecules/Chatbot/components/PreDefinedQuestions.tsx b/src/components/Molecules/Chatbot/components/PreDefinedQuestions.tsx new file mode 100644 index 0000000..a129f20 --- /dev/null +++ b/src/components/Molecules/Chatbot/components/PreDefinedQuestions.tsx @@ -0,0 +1,16 @@ +export const PredefinedQuestions: React.FC<{ + questions: string[]; + onClick: (q: string) => void; +}> = ({ questions, onClick }) => ( +
+ {questions.map((q, i) => ( + + ))} +
+); diff --git a/src/components/Molecules/Chatbot/index.tsx b/src/components/Molecules/Chatbot/index.tsx new file mode 100644 index 0000000..9bf665b --- /dev/null +++ b/src/components/Molecules/Chatbot/index.tsx @@ -0,0 +1,97 @@ +import React, { useState, useRef } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { FiMessageCircle } from "react-icons/fi"; +import { useClickOutside } from "@/hooks/useClickOutside"; +import { ChatHeader } from "./components/ChatHeader"; +import { ChatBubble } from "./components/ChatBubble"; +import { PredefinedQuestions } from "./components/PreDefinedQuestions"; +import { ChatInput } from "./components/ChatInput"; +import { ChatMessage, FloatingChatbotWidgetProps } from "./Chatbot.types"; + +const FloatingChatbotWidget: React.FC = ({ + initialMessages = [ + { text: "Hello! How can I help you today?", isUser: false }, + ], + predefinedQuestions = [], + title = "Yeti Assistant 🤖", +}) => { + const [isOpen, setIsOpen] = useState(false); + const [messages, setMessages] = useState(initialMessages); + const [input, setInput] = useState(""); + const chatRef = useRef(null); + + useClickOutside(chatRef, () => setIsOpen(false)); + + const handleSend = (msg?: string) => { + const messageToSend = msg || input; + if (!messageToSend.trim()) return; + setMessages((prev) => [...prev, { text: messageToSend, isUser: true }]); + setInput(""); + + setTimeout(() => { + const botReply = getBotReply(messageToSend); + setMessages((prev) => [...prev, { text: botReply, isUser: false }]); + }, 600); + }; + + const getBotReply = (message: string): string => { + const lower = message.toLowerCase(); + if (lower.includes("hello") || lower.includes("hi")) { + return "Hi there! 👋 How can I assist you today?"; + } else if (lower.includes("story")) { + return "Once upon a time, in a snowy mountain village, a curious yeti dreamed of learning to code..."; + } else if (lower.includes("help")) { + return "Sure! I can help you with stories, questions, or just chat. What would you like to do?"; + } else if (lower.includes("bye")) { + return "Goodbye! Have a great day ❄️"; + } else { + return `You said: "${message}"`; + } + }; + + return ( +
+ + {isOpen && ( + + setIsOpen(false)} /> + +
+ {messages.map((msg, idx) => ( + + ))} + {predefinedQuestions.length > 0 && ( + + )} +
+ + handleSend()} + /> +
+ )} +
+ + +
+ ); +}; + +export default FloatingChatbotWidget; diff --git a/src/components/Molecules/Datagrid/index.tsx b/src/components/Molecules/Datagrid/index.tsx index 5408bcc..0898cfc 100644 --- a/src/components/Molecules/Datagrid/index.tsx +++ b/src/components/Molecules/Datagrid/index.tsx @@ -50,6 +50,7 @@ function DataGrid({ pageIndex: 0, pageSize: pageSize, }); + const pageItemsList = [ { label: "10", diff --git a/src/components/custom/DatePicker/DatePicker.stories.tsx b/src/components/custom/DatePicker/DatePicker.stories.tsx index 18b84fe..e0d8cae 100644 --- a/src/components/custom/DatePicker/DatePicker.stories.tsx +++ b/src/components/custom/DatePicker/DatePicker.stories.tsx @@ -1,6 +1,7 @@ import type { Meta } from "@storybook/react"; -import { DatePickerPresets, DatePicker } from "./index"; +import { DatePicker } from "./index"; import { useState } from "react"; +import { DatePickerPresets } from "./DatePicker.types"; const meta = { title: "Custom/DatePicker", @@ -345,13 +346,7 @@ const DefaultDatePicker = () => { onCancel={() => { setIsOpen(false); }} - onSave={(dates) => { - alert("Saved date range: " + JSON.stringify(dates)); - console.log("Saved date range:", { - startDate: dates.start, - endDate: dates.end, - preset: dates.preset, - }); + onSave={() => { setIsOpen(false); }} /> diff --git a/src/components/custom/DatePicker/DatePicker.types.ts b/src/components/custom/DatePicker/DatePicker.types.ts new file mode 100644 index 0000000..3a273f3 --- /dev/null +++ b/src/components/custom/DatePicker/DatePicker.types.ts @@ -0,0 +1,60 @@ +export enum DatePickerPresets { + today = "Today", + yesterday = "Yesterday", + lastWeek = "Last Week", + lastMonth = "Last Month", + lastThreeMonths = "Last 3 Months", + custom = "Custom", +} + +export interface DatePickerProps { + /** + * The start date of the date range + */ + startDate: Date | null; + /** + * The end date of the date range + */ + endDate: Date | null; + /** + * Callback function that is called when the date range changes + */ + onChange: (dates: [Date | null, Date | null]) => void; + /** + * Currently selected preset + */ + selectedPreset: DatePickerPresets; + /** + * Additional CSS classes to apply to the component + */ + className?: string; + /** + * Optional callback function that is called when the cancel button is clicked + */ + onCancel?: () => void; + /** + * Optional callback function that is called when the save button is clicked + */ + onSave?: (dates: { + start: string | null; + end: string | null; + preset: DatePickerPresets; + }) => void; + + /** + * Controls the open/closed state of the date picker + */ + isOpen: boolean; + + /** + * Callback when open state changes + */ + setIsOpen: (isOpen: boolean) => void; +} + +export interface DateTriggerButtonProps { + startDate: Date | null; + endDate: Date | null; + onClick: () => void; + className?: string; +} diff --git a/src/components/custom/DatePicker/components/AnimatePresenceWrapper/index.tsx b/src/components/custom/DatePicker/components/AnimatePresenceWrapper/index.tsx new file mode 100644 index 0000000..46c3ee9 --- /dev/null +++ b/src/components/custom/DatePicker/components/AnimatePresenceWrapper/index.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; + +interface AnimatePresenceWrapperProps { + isOpen: boolean; + children: React.ReactNode; + className?: string; + origin?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; +} + +const dropdownOriginClassMap: Record = { + "top-left": "top-0 left-0", + "top-right": "top-0 right-0", + "bottom-left": "bottom-0 left-0", + "bottom-right": "bottom-0 right-0", +}; + +export const AnimatePresenceWrapper: React.FC = ({ + isOpen, + children, + className = "", + origin = "top-right", +}) => { + return ( + + {isOpen && ( + + {children} + + )} + + ); +}; diff --git a/src/components/custom/DatePicker/components/DataTriggerButton.tsx b/src/components/custom/DatePicker/components/DataTriggerButton.tsx new file mode 100644 index 0000000..7d2cd1a --- /dev/null +++ b/src/components/custom/DatePicker/components/DataTriggerButton.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { FiCalendar } from "react-icons/fi"; +import dayjs from "dayjs"; +import Button from "@/components/Atoms/Controls/Button"; +import { DateTriggerButtonProps } from "../DatePicker.types"; + +const DateTriggerButton: React.FC = ({ + startDate, + endDate, + onClick, + className = "", +}) => { + const displayText = + startDate && endDate + ? `${dayjs(startDate).format("MMM DD")} - ${dayjs(endDate).format( + "MMM DD, YYYY", + )}` + : "Select Date Range"; + + return ( + + ); +}; + +export default DateTriggerButton; diff --git a/src/components/custom/DatePicker/components/DateRangePicker.tsx b/src/components/custom/DatePicker/components/DateRangePicker.tsx new file mode 100644 index 0000000..84c42ed --- /dev/null +++ b/src/components/custom/DatePicker/components/DateRangePicker.tsx @@ -0,0 +1,115 @@ +import dayjs from "dayjs"; +import Button from "@/components/Atoms/Controls/Button"; +import { DateRange } from "react-date-range"; +import { DatePickerPresets, DatePickerProps } from "../DatePicker.types"; +import { presetsToDateRange } from "@/utils/presetsToDateRange"; + +/** + * DateRangePicker is a component that allows users to select a date range. + * It supports both manual date selection and preset ranges like Today, Yesterday, Last Week, etc. + */ +export const DateRangePicker: React.FC = ({ + startDate, + endDate, + onChange, + selectedPreset, + className, + onCancel, + onSave, + isOpen, +}) => { + const handleRangeChange = (ranges: any) => { + const { selection } = ranges; + const start = dayjs(selection.startDate); + const end = dayjs(selection.endDate); + + if (end.diff(start, "months") > 3) { + return; + } + + onChange([selection.startDate, selection.endDate]); + }; + + const handlePresetChange = (preset: DatePickerPresets) => { + const [presetStart, presetEnd] = presetsToDateRange(preset); + onChange([presetStart, presetEnd]); + }; + + if (!isOpen) { + return null; + } + + return ( +
+
+
+ +
+
+
Presets
+ {Object.values(DatePickerPresets) + .filter((preset) => preset !== DatePickerPresets.custom) + .map((preset) => ( +
handlePresetChange(preset)} + > + {preset} +
+ ))} +
+
+
+ {onCancel && ( + + )} + {onSave && ( + + )} +
+
+ ); +}; diff --git a/src/components/custom/DatePicker/index.tsx b/src/components/custom/DatePicker/index.tsx index cefb276..db1d7c5 100644 --- a/src/components/custom/DatePicker/index.tsx +++ b/src/components/custom/DatePicker/index.tsx @@ -1,208 +1,11 @@ -import { DateRange } from "react-date-range"; import dayjs from "dayjs"; -import Button from "@/components/Atoms/Controls/Button"; -import { useState, useRef, useEffect } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { FiCalendar } from "react-icons/fi"; - -export enum DatePickerPresets { - today = "Today", - yesterday = "Yesterday", - lastWeek = "Last Week", - lastMonth = "Last Month", - lastThreeMonths = "Last 3 Months", - custom = "Custom", -} - -export interface DatePickerProps { - /** - * The start date of the date range - */ - startDate: Date | null; - /** - * The end date of the date range - */ - endDate: Date | null; - /** - * Callback function that is called when the date range changes - */ - onChange: (dates: [Date | null, Date | null]) => void; - /** - * Currently selected preset - */ - selectedPreset: DatePickerPresets; - /** - * Additional CSS classes to apply to the component - */ - className?: string; - /** - * Optional callback function that is called when the cancel button is clicked - */ - onCancel?: () => void; - /** - * Optional callback function that is called when the save button is clicked - */ - onSave?: (dates: { - start: string | null; - end: string | null; - preset: DatePickerPresets; - }) => void; - - /** - * Controls the open/closed state of the date picker - */ - isOpen: boolean; - - /** - * Callback when open state changes - */ - setIsOpen: (isOpen: boolean) => void; -} - -export const presetsToDateRange = (preset: DatePickerPresets): [Date, Date] => { - const now = dayjs(); - switch (preset) { - case DatePickerPresets.today: - return [now.startOf("day").toDate(), now.endOf("day").toDate()]; - case DatePickerPresets.yesterday: - return [ - now.subtract(1, "day").startOf("day").toDate(), - now.subtract(1, "day").endOf("day").toDate(), - ]; - case DatePickerPresets.lastWeek: - return [ - now.subtract(1, "week").startOf("week").toDate(), - now.subtract(1, "week").endOf("week").toDate(), - ]; - case DatePickerPresets.lastMonth: - return [ - now.subtract(1, "month").startOf("month").toDate(), - now.subtract(1, "month").endOf("month").toDate(), - ]; - case DatePickerPresets.lastThreeMonths: - return [ - now.subtract(3, "month").startOf("month").toDate(), - now.endOf("month").toDate(), - ]; - default: - return [now.startOf("day").toDate(), now.endOf("day").toDate()]; - } -}; - -/** - * DateRangePicker is a component that allows users to select a date range. - * It supports both manual date selection and preset ranges like Today, Yesterday, Last Week, etc. - */ -const DateRangePicker: React.FC = ({ - startDate, - endDate, - onChange, - selectedPreset, - className, - onCancel, - onSave, - isOpen, -}) => { - const handleRangeChange = (ranges: any) => { - const { selection } = ranges; - const start = dayjs(selection.startDate); - const end = dayjs(selection.endDate); - - if (end.diff(start, "months") > 3) { - return; - } - - onChange([selection.startDate, selection.endDate]); - }; - - const handlePresetChange = (preset: DatePickerPresets) => { - const [presetStart, presetEnd] = presetsToDateRange(preset); - onChange([presetStart, presetEnd]); - }; - - if (!isOpen) { - return null; - } - - return ( -
-
-
- -
-
-
Presets
- {Object.values(DatePickerPresets) - .filter((preset) => preset !== DatePickerPresets.custom) - .map((preset) => ( -
handlePresetChange(preset)} - > - {preset} -
- ))} -
-
-
- {onCancel && ( - - )} - {onSave && ( - - )} -
-
- ); -}; +import { useState, useRef, useCallback } from "react"; +import { useClickOutside } from "@/hooks/useClickOutside"; +import { DateRangePicker } from "./components/DateRangePicker"; +import { DatePickerPresets, DatePickerProps } from "./DatePicker.types"; +import DateTriggerButton from "./components/DataTriggerButton"; +import { AnimatePresenceWrapper } from "./components/AnimatePresenceWrapper"; +import { presetsToDateRange } from "@/utils/presetsToDateRange"; /** * DatePicker is a component that allows users to select a date range. @@ -212,86 +15,71 @@ export const DatePicker: React.FC< Omit > = (props) => { const [selectedPreset, setSelectedPreset] = useState(props.selectedPreset); - const { startDate, endDate } = props; + const { startDate, endDate, onCancel, onSave, onChange } = props; const triggerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - triggerRef.current && - !triggerRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [setIsOpen]); + useClickOutside(triggerRef, () => setIsOpen(false)); + + const handleToggleOpen = useCallback(() => { + setIsOpen((prev) => !prev); + }, []); + + const handleCancel = useCallback(() => { + onCancel?.(); + setIsOpen(false); + }, [onCancel]); + + const handleSave = useCallback(() => { + if (!onSave) return; + onSave({ + start: startDate ? dayjs(startDate).format() : null, + end: endDate ? dayjs(endDate).format() : null, + preset: selectedPreset, + }); + setIsOpen(false); + }, [startDate, endDate, selectedPreset, onSave]); + + const handleRangeChange = useCallback( + (dates: [Date | null, Date | null]) => { + onChange(dates); + + const [start, end] = dates; + const matchedPreset = Object.values(DatePickerPresets).find((preset) => { + const [presetStart, presetEnd] = presetsToDateRange(preset); + return ( + dayjs(start).isSame(presetStart, "day") && + dayjs(end).isSame(presetEnd, "day") + ); + }); + + setSelectedPreset(matchedPreset || DatePickerPresets.custom); + }, + [onChange], + ); return (
- - - - {isOpen && ( - - { - if (props.onCancel) props.onCancel(); - setIsOpen(false); - }} - onSave={(dates) => { - if (props.onSave) { - props.onSave(dates); - } - setIsOpen(false); - }} - onChange={(dates) => { - props.onChange(dates); - const [start, end] = dates; - const preset = Object.values(DatePickerPresets).find( - (preset) => { - const [presetStart, presetEnd] = presetsToDateRange(preset); - return ( - dayjs(start).isSame(presetStart, "day") && - dayjs(end).isSame(presetEnd, "day") - ); - }, - ); - setSelectedPreset(preset || DatePickerPresets.custom); - }} - /> - - )} - + +
); }; diff --git a/src/components/custom/ShopPicker/ShopPicker.types.ts b/src/components/custom/ShopPicker/ShopPicker.types.ts new file mode 100644 index 0000000..5cf630e --- /dev/null +++ b/src/components/custom/ShopPicker/ShopPicker.types.ts @@ -0,0 +1,25 @@ +export type ShopType = "shopify" | "shopware5" | "shopware6"; + +export interface BaseShop { + name: string; + isActive: boolean; + type: ShopType; + uid?: string; + [key: string]: any; +} + +export interface SubShop extends BaseShop {} + +export interface Shop extends BaseShop { + subShops?: SubShop[]; +} + +export interface ShopPickerProps + extends Omit, "onChange"> { + shopList: Shop[]; + onChange: (value: Shop, event: React.MouseEvent) => void; + className?: string; + maxHeight?: string; + activeShop?: Shop; + extraElements?: React.ReactNode[]; +} diff --git a/src/components/custom/ShopPicker/components/SearchInput.tsx b/src/components/custom/ShopPicker/components/SearchInput.tsx new file mode 100644 index 0000000..eea1170 --- /dev/null +++ b/src/components/custom/ShopPicker/components/SearchInput.tsx @@ -0,0 +1,20 @@ +import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; + +interface Props { + searchTerm: string; + onChange: (e: React.ChangeEvent) => void; +} + +export const SearchInput: React.FC = ({ searchTerm, onChange }) => ( +
+ + +
+); diff --git a/src/components/custom/ShopPicker/components/ShopDropdown.tsx b/src/components/custom/ShopPicker/components/ShopDropdown.tsx new file mode 100644 index 0000000..e681702 --- /dev/null +++ b/src/components/custom/ShopPicker/components/ShopDropdown.tsx @@ -0,0 +1,66 @@ +import { motion, AnimatePresence } from "framer-motion"; +import Line from "@/components/Atoms/Misc/Line"; +import { Shop } from "../ShopPicker.types"; +import { SearchInput } from "./SearchInput"; +import { ShopItem } from "./ShopItem/ShopItem"; + +interface Props { + isOpen: boolean; + searchTerm: string; + extraElements?: React.ReactNode[]; + shopList: Shop[]; + openShops: Set; + handleSelect: ( + selectedShop: Shop, + event: React.MouseEvent, + ) => void; + toggleShop: (shopName: string) => void; + onSearchChange: (e: React.ChangeEvent) => void; +} + +export const ShopDropdown: React.FC = ({ + isOpen, + shopList, + searchTerm, + extraElements, + openShops, + handleSelect, + toggleShop, + onSearchChange, +}) => { + return ( + + {isOpen && ( + + + + {extraElements?.map((element, index) => ( +
+ {element} +
+ ))} + + + {shopList.map((shop, index) => ( + toggleShop(shop.name)} + /> + ))} + + +
+ )} +
+ ); +}; diff --git a/src/components/custom/ShopPicker/components/ShopItem/AnimatedChevron.tsx b/src/components/custom/ShopPicker/components/ShopItem/AnimatedChevron.tsx new file mode 100644 index 0000000..d9ed524 --- /dev/null +++ b/src/components/custom/ShopPicker/components/ShopItem/AnimatedChevron.tsx @@ -0,0 +1,11 @@ +import { ChevronRightIcon } from "@radix-ui/react-icons"; +import { motion } from "framer-motion"; + +export const AnimatedChevron: React.FC<{ isOpen: boolean }> = ({ isOpen }) => ( + + + +); diff --git a/src/components/custom/ShopPicker/components/ShopItem/ShopEntryButton.tsx b/src/components/custom/ShopPicker/components/ShopItem/ShopEntryButton.tsx new file mode 100644 index 0000000..d621529 --- /dev/null +++ b/src/components/custom/ShopPicker/components/ShopItem/ShopEntryButton.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { motion } from "framer-motion"; +import { ShopLogo } from "../ShopLogo"; +import { StatusIndicator } from "../StatusIndicator"; +import { Shop, SubShop } from "../../ShopPicker.types"; + +interface Props { + shop: Shop | SubShop; + isSubShop?: boolean; + hasSubShops?: boolean; + isOpen?: boolean; + onClick: (e: React.MouseEvent) => void; + children?: React.ReactNode; // For chevron or custom indicators +} + +export const ShopEntryButton: React.FC = ({ + shop, + isSubShop = false, + onClick, + children, +}) => ( + +
+ + {shop.name || `${shop.uid} [UNNAMED]`} +
+
+ + {children} +
+
+); diff --git a/src/components/custom/ShopPicker/components/ShopItem/ShopItem.tsx b/src/components/custom/ShopPicker/components/ShopItem/ShopItem.tsx new file mode 100644 index 0000000..936307c --- /dev/null +++ b/src/components/custom/ShopPicker/components/ShopItem/ShopItem.tsx @@ -0,0 +1,64 @@ +import { AnimatePresence, motion } from "framer-motion"; +import React, { useCallback } from "react"; +import { BaseShop, Shop, SubShop } from "../../ShopPicker.types"; +import { ShopEntryButton } from "./ShopEntryButton"; +import { AnimatedChevron } from "./AnimatedChevron"; +import { SubShopList } from "./SubShopList"; + +export const ShopItem: React.FC<{ + shop: Shop; + onSelect: ( + shop: Shop | SubShop, + event: React.MouseEvent, + ) => void; + isSubShop?: boolean; + searchTerm: string; + isOpen: boolean; + onToggle: () => void; +}> = React.memo(({ shop, onSelect, searchTerm, isOpen, onToggle }) => { + const handleClick = useCallback( + (event: React.MouseEvent) => { + shop.subShops?.length ? onToggle() : onSelect(shop, event); + }, + [shop, onSelect, onToggle], + ); + + const matchesSearch = (item: BaseShop) => + item.name.toLowerCase().includes(searchTerm.toLowerCase()); + + if ( + searchTerm && + !matchesSearch(shop) && + !shop.subShops?.some(matchesSearch) + ) { + return null; + } + + return ( + + + {shop.subShops && } + + + + {(isOpen || (searchTerm && shop.subShops?.some(matchesSearch))) && + shop.subShops && ( + + )} + + + ); +}); diff --git a/src/components/custom/ShopPicker/components/ShopItem/SubShopList.tsx b/src/components/custom/ShopPicker/components/ShopItem/SubShopList.tsx new file mode 100644 index 0000000..eb85578 --- /dev/null +++ b/src/components/custom/ShopPicker/components/ShopItem/SubShopList.tsx @@ -0,0 +1,45 @@ +import { motion } from "framer-motion"; +import { BaseShop, SubShop } from "../../ShopPicker.types"; +import { ShopLogo } from "../ShopLogo"; +import { StatusIndicator } from "../StatusIndicator"; + +interface Props { + subShops: SubShop[]; + searchTerm: string; + onSelect: (shop: SubShop, event: React.MouseEvent) => void; + matchesSearch: (item: BaseShop) => boolean; +} + +export const SubShopList: React.FC = ({ + subShops, + searchTerm, + onSelect, + matchesSearch, +}) => { + const filtered = subShops.filter((s) => !searchTerm || matchesSearch(s)); + return ( + + {filtered.map((subShop, index) => ( + onSelect(subShop, e)} + whileTap={{ scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + animate={{ opacity: 1, scale: 1 }} + > +
+ + {subShop.name || `${subShop.uid} [UNNAMED]`} +
+ +
+ ))} +
+ ); +}; diff --git a/src/components/custom/ShopPicker/components/ShopLogo.tsx b/src/components/custom/ShopPicker/components/ShopLogo.tsx new file mode 100644 index 0000000..cd98d64 --- /dev/null +++ b/src/components/custom/ShopPicker/components/ShopLogo.tsx @@ -0,0 +1,9 @@ +import { ShopType } from "../ShopPicker.types"; + +export const ShopLogo = ({ type }: { type: ShopType }) => ( + {`${type.charAt(0).toUpperCase() +); diff --git a/src/components/custom/ShopPicker/components/ShopTriggerButton.tsx b/src/components/custom/ShopPicker/components/ShopTriggerButton.tsx new file mode 100644 index 0000000..0ba362b --- /dev/null +++ b/src/components/custom/ShopPicker/components/ShopTriggerButton.tsx @@ -0,0 +1,44 @@ +import { motion } from "framer-motion"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; + +import { tv } from "tailwind-variants"; +import { ShopLogo } from "./ShopLogo"; +import { StatusIndicator } from "./StatusIndicator"; +import { Shop } from "../ShopPicker.types"; + +interface Props { + activeShop: Shop; + isOpen: boolean; + onClick: () => void; +} + +export const ShopTriggerButton: React.FC = ({ + activeShop, + isOpen, + onClick, +}) => ( + +
+ + {activeShop.name || `${activeShop.uid} [UNNAMED]`} +
+
+
+ + + {activeShop.isActive ? "Active" : "Inactive"} + +
+ +
+
+); diff --git a/src/components/custom/ShopPicker/components/StatusIndicator.tsx b/src/components/custom/ShopPicker/components/StatusIndicator.tsx new file mode 100644 index 0000000..7d82aa2 --- /dev/null +++ b/src/components/custom/ShopPicker/components/StatusIndicator.tsx @@ -0,0 +1,8 @@ +import { IoDiscSharp } from "react-icons/io5"; + +export const StatusIndicator = ({ isActive }: { isActive: boolean }) => ( + +); diff --git a/src/components/custom/ShopPicker/index.tsx b/src/components/custom/ShopPicker/index.tsx index f394e7c..90629c8 100644 --- a/src/components/custom/ShopPicker/index.tsx +++ b/src/components/custom/ShopPicker/index.tsx @@ -1,312 +1,97 @@ -import React, { - useState, - useCallback, - useRef, - useEffect, - useMemo, -} from "react"; -import { - ChevronDownIcon, - ChevronRightIcon, - MagnifyingGlassIcon, -} from "@radix-ui/react-icons"; -import { tv } from "tailwind-variants"; -import { IoDiscSharp } from "react-icons/io5"; -import { AnimatePresence, motion } from "framer-motion"; -import Line from "@/components/Atoms/Misc/Line"; - -type ShopType = "shopify" | "shopware5" | "shopware6"; - -interface BaseShop { - name: string; - isActive: boolean; - type: ShopType; - uid: string; - [key: string]: any; -} - -interface SubShop extends BaseShop {} - -interface Shop extends BaseShop { - subShops?: SubShop[]; -} - -interface ShopPickerProps - extends Omit, "onChange"> { - shopList: Shop[]; - onChange: (value: Shop, event: React.MouseEvent) => void; - className?: string; - maxHeight?: string; - activeShop?: Shop; - extraElements?: React.ReactNode[]; -} - -const ShopLogo = ({ type }: { type: ShopType }) => ( - {`${type.charAt(0).toUpperCase() -); - -const StatusIndicator = ({ isActive }: { isActive: boolean }) => ( - -); - -const ShopItem: React.FC<{ - shop: Shop; - onSelect: ( - shop: Shop | SubShop, - event: React.MouseEvent, - ) => void; - isSubShop?: boolean; - searchTerm: string; - isOpen: boolean; - onToggle: () => void; -}> = React.memo( - ({ shop, onSelect, isSubShop = false, searchTerm, isOpen, onToggle }) => { - const handleClick = useCallback( - (event: React.MouseEvent) => { - shop.subShops?.length ? onToggle() : onSelect(shop, event); - }, - [shop, onSelect, onToggle], - ); - - const matchesSearch = (item: BaseShop) => - item.name.toLowerCase().includes(searchTerm.toLowerCase()); - - if ( - searchTerm && - !matchesSearch(shop) && - !shop.subShops?.some(matchesSearch) - ) { - return null; - } - - return ( - - -
- - {shop.name && shop.name != "" - ? shop.name - : shop?.uid + " [UNNAMED]"} -
-
- - {shop.subShops && shop.subShops?.length > 0 && ( - - - - )} -
-
- - - {(isOpen || (searchTerm && shop.subShops?.some(matchesSearch))) && - shop.subShops && ( - - {shop.subShops - .filter((subShop) => !searchTerm || matchesSearch(subShop)) - .map((subShop, index) => ( - onSelect(subShop, e)} - whileTap={{ scale: 0.95 }} - initial={{ opacity: 0, scale: 0.95 }} - animate={{ opacity: 1, scale: 1 }} - > -
- - {subShop.name && subShop.name != "" - ? subShop.name - : subShop?.uid + " [UNNAMED]"} -
- -
- ))} -
- )} -
-
- ); - }, -); - -const ShopPicker: React.FC = React.memo( - ({ shopList, onChange, className, activeShop, extraElements }) => { - const [isOpen, setIsOpen] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [openShops, setOpenShops] = useState>(new Set()); - const shopPickerRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (!shopPickerRef.current?.contains(event.target as Node)) { - setIsOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => - document.removeEventListener("mousedown", handleClickOutside); - }, []); - - const matchingShops = useMemo(() => { - if (!searchTerm) return new Set(); - const term = searchTerm.toLowerCase(); - return new Set( - shopList - .filter( - (shop) => - shop.name.toLowerCase().includes(term) || - shop.subShops?.some((subShop) => - subShop.name.toLowerCase().includes(term), - ), - ) - .map((shop) => shop.name), - ); - }, [shopList, searchTerm]); - - const handleSearchChange = useCallback( - (e: React.ChangeEvent) => { - const newTerm = e.target.value; - setSearchTerm(newTerm); - setOpenShops(newTerm ? matchingShops : new Set()); - }, - [matchingShops], - ); - - const handleSelect = useCallback( - ( - selectedShop: Shop | SubShop, - event: React.MouseEvent, - ) => { - onChange(selectedShop, event); +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { Shop, ShopPickerProps, SubShop } from "./ShopPicker.types"; +import { ShopTriggerButton } from "./components/ShopTriggerButton"; +import { ShopDropdown } from "./components/ShopDropdown"; + +export const ShopPicker: React.FC = ({ + shopList, + onChange, + className, + activeShop, + extraElements, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [openShops, setOpenShops] = useState>(new Set()); + const shopPickerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (!shopPickerRef.current?.contains(event.target as Node)) { setIsOpen(false); - setSearchTerm(""); - setOpenShops(new Set()); - }, - [onChange], + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const matchingShops = useMemo(() => { + if (!searchTerm) return new Set(); + const term = searchTerm.toLowerCase(); + return new Set( + shopList + .filter( + (shop) => + shop.name.toLowerCase().includes(term) || + shop.subShops?.some((subShop) => + subShop.name.toLowerCase().includes(term), + ), + ) + .map((shop) => shop.name), ); - - const toggleShop = useCallback((shopName: string) => { - setOpenShops((prev) => { - const newSet = new Set(prev); - prev.has(shopName) ? newSet.delete(shopName) : newSet.add(shopName); - return newSet; - }); - }, []); - - return ( -
- ) => { + const newTerm = e.target.value; + setSearchTerm(newTerm); + setOpenShops(newTerm ? matchingShops : new Set()); + }, + [matchingShops], + ); + + const handleSelect = useCallback( + ( + selectedShop: Shop | SubShop, + event: React.MouseEvent, + ) => { + onChange(selectedShop, event); + setIsOpen(false); + setSearchTerm(""); + setOpenShops(new Set()); + }, + [onChange], + ); + + const toggleShop = useCallback((shopName: string) => { + setOpenShops((prev) => { + const newSet = new Set(prev); + prev.has(shopName) ? newSet.delete(shopName) : newSet.add(shopName); + return newSet; + }); + }, []); + + return ( +
+ {activeShop && ( + setIsOpen(!isOpen)} - className={tv({ - base: "bg-white w-full bg-opacity-10 transition-all ease-in-out duration-300 text-white flex items-center justify-between body-3 rounded-xl p-3 gap-6", - })()} - whileTap={{ scale: 0.98 }} - > - {activeShop && ( - <> -
- - {activeShop.name && activeShop.name != "" - ? activeShop.name - : activeShop?.uid + " [UNNAMED]"} -
-
-
- - - {activeShop.isActive ? "Active" : "Inactive"} - -
- -
- - )} - - - - {isOpen && ( - -
-
- - -
-
- - <> - {extraElements && - extraElements.map((element, index) => ( -
- {element} -
- ))} - - - - {shopList.map((shop, index) => ( - toggleShop(shop.name)} - /> - ))} - - -
- )} -
-
- ); - }, -); + /> + )} + +
+ ); +}; export default ShopPicker; diff --git a/src/components/index.tsx b/src/components/index.tsx index 96b1b97..ade9906 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -1,50 +1,80 @@ // This file is auto-generated. Do not edit manually. // Atoms exports -export { default as Skeleton } from '@/components/Atoms/Skeleton'; -export { Table,TableHeader,TableBody,TableFooter,TableRow,TableHead,TableCell,TableCaption,TableComponent } from '@/components/Atoms/Data_Display/Table'; -export { default as Legend } from '@/components/Atoms/Data_Display/Legend'; -export { default as Label } from '@/components/Atoms/Data_Display/Label'; -export { default as ColorBar } from '@/components/Atoms/Data_Display/ColorBar'; -export { default as PaginatedDisplay } from '@/components/Atoms/Data_Display/PaginatedDisplay'; -export { default as Button } from '@/components/Atoms/Controls/Button'; -export { BidirectionalSlider } from '@/components/Atoms/Controls/BidirectionalSlider'; -export { BulletSlider } from '@/components/Atoms/Controls/BulletSlider'; -export { default as RadioButton } from '@/components/Atoms/Controls/RadioButton'; -export { default as Link } from '@/components/Atoms/Controls/Link'; -export { default as Slider } from '@/components/Atoms/Controls/Slider'; -export { default as Switch } from '@/components/Atoms/Controls/Switch'; -export { default as Input } from '@/components/Atoms/Controls/Input'; -export { default as Textarea } from '@/components/Atoms/Controls/Textarea'; -export { default as Cards } from '@/components/Atoms/Layout/Cards'; -export { Tooltip } from '@/components/Atoms/Misc/Tooltip'; -export { default as Line } from '@/components/Atoms/Misc/Line'; -export { default as Badge } from '@/components/Atoms/Misc/Badge'; +export { default as Skeleton } from "@/components/Atoms/Skeleton"; +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableRow, + TableHead, + TableCell, + TableCaption, + TableComponent, +} from "@/components/Atoms/Data_Display/Table"; +export { default as Legend } from "@/components/Atoms/Data_Display/Legend"; +export { default as Label } from "@/components/Atoms/Data_Display/Label"; +export { default as ColorBar } from "@/components/Atoms/Data_Display/ColorBar"; +export { default as PaginatedDisplay } from "@/components/Atoms/Data_Display/PaginatedDisplay"; +export { default as Button } from "@/components/Atoms/Controls/Button"; +export { BidirectionalSlider } from "@/components/Atoms/Controls/BidirectionalSlider"; +export { BulletSlider } from "@/components/Atoms/Controls/BulletSlider"; +export { default as RadioButton } from "@/components/Atoms/Controls/RadioButton"; +export { default as Link } from "@/components/Atoms/Controls/Link"; +export { default as Slider } from "@/components/Atoms/Controls/Slider"; +export { default as Switch } from "@/components/Atoms/Controls/Switch"; +export { default as Input } from "@/components/Atoms/Controls/Input"; +export { default as Textarea } from "@/components/Atoms/Controls/Textarea"; +export { default as Cards } from "@/components/Atoms/Layout/Cards"; +export { Tooltip } from "@/components/Atoms/Misc/Tooltip"; +export { default as Line } from "@/components/Atoms/Misc/Line"; +export { default as Badge } from "@/components/Atoms/Misc/Badge"; // TODO: No exports found in Popover ('@/components/Atoms/Misc/Popover') // Molecules exports -export { default as PieChart } from '@/components/Molecules/Charts/PieChart'; -export { default as GroupBarChart } from '@/components/Molecules/Charts/GroupBarChart'; -export { CheckboxDropdown } from '@/components/Molecules/CheckboxDropdown'; -export { default as ModalGroup } from '@/components/Molecules/ModalGroup'; -export { default as ChannelSelector } from '@/components/Molecules/ChannelSelector'; -export { default as Datagrid } from '@/components/Molecules/Datagrid'; -export { default as Dropdowns } from '@/components/Molecules/Dropdowns'; -export { default as Form } from '@/components/Molecules/Form'; -export { ConnectionCard } from '@/components/Molecules/ConnectionCard'; -export { TabsTriggerV2,TabsContentV2,TabsListV2,TabsV2,TabsContextProviderV2 } from '@/components/Molecules/Tab/V2'; -export { TabsTrigger,TabsContent,TabsList,Tabs,TabsContextProvider } from '@/components/Molecules/Tab'; -export { default as Modal } from '@/components/Molecules/Modal'; -export { StepStatus } from '@/components/Molecules/StepStatus'; +export { default as PieChart } from "@/components/Molecules/Charts/PieChart"; +export { default as ChatBot } from "@/components/Molecules/Chatbot"; +export { default as GroupBarChart } from "@/components/Molecules/Charts/GroupBarChart"; +export { CheckboxDropdown } from "@/components/Molecules/CheckboxDropdown"; +export { default as ModalGroup } from "@/components/Molecules/ModalGroup"; +export { default as ChannelSelector } from "@/components/Molecules/ChannelSelector"; +export { default as Datagrid } from "@/components/Molecules/Datagrid"; +export { default as Dropdowns } from "@/components/Molecules/Dropdowns"; +export { default as Form } from "@/components/Molecules/Form"; +export { ConnectionCard } from "@/components/Molecules/ConnectionCard"; +export { + TabsTriggerV2, + TabsContentV2, + TabsListV2, + TabsV2, + TabsContextProviderV2, +} from "@/components/Molecules/Tab/V2"; +export { + TabsTrigger, + TabsContent, + TabsList, + Tabs, + TabsContextProvider, +} from "@/components/Molecules/Tab"; +export { default as Modal } from "@/components/Molecules/Modal"; +export { StepStatus } from "@/components/Molecules/StepStatus"; // Loaders exports -export { default as Spinner } from '@/components/Loaders/Spinner'; +export { default as Spinner } from "@/components/Loaders/Spinner"; // custom exports -export { default as ShopPicker } from '@/components/custom/ShopPicker'; -export { SetupSidebar,SetupSidebarItem,SetupSidebarMenu } from '@/components/custom/SetupSideBar'; -export { default as CompanyBadge } from '@/components/custom/CompanyBadge'; -export { DashboardSidebar,DashboardSidebarItem,DashboardSidebarMenu } from '@/components/custom/DashboardSidebar'; -export { default as avatar } from '@/components/custom/avatar'; -export { presetsToDateRange,DatePicker } from '@/components/custom/DatePicker'; - +export { default as ShopPicker } from "@/components/custom/ShopPicker"; +export { + SetupSidebar, + SetupSidebarItem, + SetupSidebarMenu, +} from "@/components/custom/SetupSideBar"; +export { default as CompanyBadge } from "@/components/custom/CompanyBadge"; +export { + DashboardSidebar, + DashboardSidebarItem, + DashboardSidebarMenu, +} from "@/components/custom/DashboardSidebar"; +export { default as avatar } from "@/components/custom/avatar"; +export { DatePicker } from "@/components/custom/DatePicker"; diff --git a/src/hooks/useClickOutside.tsx b/src/hooks/useClickOutside.tsx new file mode 100644 index 0000000..b652313 --- /dev/null +++ b/src/hooks/useClickOutside.tsx @@ -0,0 +1,24 @@ +import { useEffect } from "react"; + +export function useClickOutside( + ref: React.RefObject, + handler: (event: MouseEvent | TouchEvent) => void, +) { + useEffect(() => { + const listener = (event: MouseEvent | TouchEvent) => { + // Do nothing if clicking ref's element or descendent elements + if (!ref.current || ref.current.contains(event.target as Node)) { + return; + } + handler(event); + }; + + document.addEventListener("mousedown", listener); + document.addEventListener("touchstart", listener); // for touch devices + + return () => { + document.removeEventListener("mousedown", listener); + document.removeEventListener("touchstart", listener); + }; + }, [ref, handler]); +} diff --git a/src/hooks/useFormField.tsx b/src/hooks/useFormField.tsx new file mode 100644 index 0000000..1fd571a --- /dev/null +++ b/src/hooks/useFormField.tsx @@ -0,0 +1,70 @@ +import { useFormContext, useWatch } from "react-hook-form"; +import { getValidationRules } from "@/utils/getValidationRules"; +import { useEffect } from "react"; + +interface UseFormFieldProps { + name: string; + type: string; + label?: string; + required?: boolean; + defaultValue?: any; + disabled?: boolean; + customValidation?: object; +} + +export function useFormField({ + name, + type, + label, + required = true, + defaultValue, + disabled, + customValidation, +}: UseFormFieldProps) { + const { + register, + setValue, + trigger, + resetField, + formState: { errors }, + control, + } = useFormContext(); + + useEffect(() => { + if (!disabled) resetField(name, { defaultValue }); + }, [disabled]); + + const fieldValue = useWatch({ control, name, defaultValue }); + + const validationRules = getValidationRules({ + name, + type, + label, + required, + customValidation, + }); + + const { ref: inputRef, ...inputProps } = register(name, validationRules); + + const handleChange = (event: any) => { + if (event?.nativeEvent) { + inputProps.onChange(event); + } else { + setValue(name, event.target.value); + } + trigger(name); + }; + + const createCustomEvent = (value: any) => ({ + target: { name, value, type }, + }); + + return { + inputRef, + inputProps, + handleChange, + createCustomEvent, + fieldValue, + error: errors[name], + }; +} diff --git a/src/hooks/usePasswordToggle.tsx b/src/hooks/usePasswordToggle.tsx new file mode 100644 index 0000000..c1736ce --- /dev/null +++ b/src/hooks/usePasswordToggle.tsx @@ -0,0 +1,24 @@ +import { useState, useCallback } from "react"; +import { IoIosEyeOff } from "react-icons/io"; +import { IoEye } from "react-icons/io5"; + +export function usePasswordToggle() { + const [showPassword, setShowPassword] = useState(false); + + const toggleVisibility = useCallback(() => { + setShowPassword((prev) => !prev); + }, []); + + const PasswordToggleIcon = ( + + ); + + return { showPassword, PasswordToggleIcon }; +} diff --git a/src/utils/getValidationRules.ts b/src/utils/getValidationRules.ts new file mode 100644 index 0000000..0f48697 --- /dev/null +++ b/src/utils/getValidationRules.ts @@ -0,0 +1,70 @@ +import { RegisterOptions } from "react-hook-form"; + +interface ValidationArgs { + name: string; + type: string; + label?: string; + required?: boolean; + customValidation?: RegisterOptions; +} + +export function getValidationRules({ + name, + type, + label, + required = true, + customValidation = {}, +}: ValidationArgs): RegisterOptions { + let rules: RegisterOptions = {}; + if (type !== "switch" && required) { + rules.required = { + value: true, + message: `${label || name} is required`, + }; + } + + switch (type) { + case "email": + rules.pattern = { + value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}$/i, + message: "Invalid email address", + }; + break; + + case "tel": + rules.pattern = { + value: /^[0-9]{10}$/, + message: "Invalid phone number (10 digits required)", + }; + break; + + case "number": + rules.valueAsNumber = true; + rules.min = { + value: 0, + message: "Value must be positive", + }; + break; + + case "password": + rules.minLength = { + value: 8, + message: "Password must be at least 8 characters long", + }; + rules.validate = (value) => { + const hasUpperCase = /[A-Z]/.test(value); + const hasLowerCase = /[a-z]/.test(value); + const hasNumbers = /\d/.test(value); + const hasSpecial = /\W/.test(value); + + return hasUpperCase && hasLowerCase && hasNumbers && hasSpecial + ? true + : "Password must contain an uppercase letter, lowercase letter, number, and special character"; + }; + break; + } + + if (customValidation) rules = { ...customValidation }; + + return rules; +} diff --git a/src/utils/presetsToDateRange.ts b/src/utils/presetsToDateRange.ts new file mode 100644 index 0000000..a68b555 --- /dev/null +++ b/src/utils/presetsToDateRange.ts @@ -0,0 +1,32 @@ +import { DatePickerPresets } from "@/components/custom/DatePicker/DatePicker.types"; +import dayjs from "dayjs"; + +export const presetsToDateRange = (preset: DatePickerPresets): [Date, Date] => { + const now = dayjs(); + switch (preset) { + case DatePickerPresets.today: + return [now.startOf("day").toDate(), now.endOf("day").toDate()]; + case DatePickerPresets.yesterday: + return [ + now.subtract(1, "day").startOf("day").toDate(), + now.subtract(1, "day").endOf("day").toDate(), + ]; + case DatePickerPresets.lastWeek: + return [ + now.subtract(1, "week").startOf("week").toDate(), + now.subtract(1, "week").endOf("week").toDate(), + ]; + case DatePickerPresets.lastMonth: + return [ + now.subtract(1, "month").startOf("month").toDate(), + now.subtract(1, "month").endOf("month").toDate(), + ]; + case DatePickerPresets.lastThreeMonths: + return [ + now.subtract(3, "month").startOf("month").toDate(), + now.endOf("month").toDate(), + ]; + default: + return [now.startOf("day").toDate(), now.endOf("day").toDate()]; + } +};