diff --git a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts index e82b28d3d..173216a87 100644 --- a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts +++ b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts @@ -10,7 +10,7 @@ import { generateApiKey, getKeyPrefix, hashApiKey } from "../../middlewares/apiK * Create a new API key pair (public + secret) for a partner * POST /v1/admin/partners/:partnerName/api-keys */ -export async function createApiKey(req: Request, res: Response): Promise { +export async function createApiKey(req: Request<{ partnerName: string }>, res: Response): Promise { try { const partnerName = req.params.partnerName as string; const { name, expiresAt } = req.body; @@ -110,7 +110,7 @@ export async function createApiKey(req: Request, res: Response): Promise { * List all API keys for a partner (by name) * GET /v1/admin/partners/:partnerName/api-keys */ -export async function listApiKeys(req: Request, res: Response): Promise { +export async function listApiKeys(req: Request<{ partnerName: string }>, res: Response): Promise { try { const partnerName = req.params.partnerName as string; @@ -180,7 +180,7 @@ export async function listApiKeys(req: Request, res: Response): Promise { * Revoke (soft delete) an API key * DELETE /v1/admin/partners/:partnerName/api-keys/:keyId */ -export async function revokeApiKey(req: Request, res: Response): Promise { +export async function revokeApiKey(req: Request<{ partnerName: string; keyId: string }>, res: Response): Promise { try { const { partnerName, keyId } = req.params; diff --git a/apps/api/src/api/controllers/maintenance.controller.ts b/apps/api/src/api/controllers/maintenance.controller.ts index e2dde1f66..0f39744a8 100644 --- a/apps/api/src/api/controllers/maintenance.controller.ts +++ b/apps/api/src/api/controllers/maintenance.controller.ts @@ -61,7 +61,7 @@ export const getAllMaintenanceSchedules: RequestHandler = async (_, res) => { * @returns {Object} 404 - Schedule not found * @returns {Object} 500 - Internal server error */ -export const updateScheduleActiveStatus: RequestHandler = async (req, res) => { +export const updateScheduleActiveStatus: RequestHandler<{ id: string }> = async (req, res) => { try { const id = req.params.id as string; const { isActive } = req.body; diff --git a/apps/frontend/.storybook/main.ts b/apps/frontend/.storybook/main.ts index 7ffb65389..1be6a6a16 100644 --- a/apps/frontend/.storybook/main.ts +++ b/apps/frontend/.storybook/main.ts @@ -6,7 +6,7 @@ import { dirname, join } from "path"; * This function is used to resolve the absolute path of a package. * It is needed in projects that use Yarn PnP or are set up within a monorepo. */ -function getAbsolutePath(value: string): any { +function getAbsolutePath(value: string) { return dirname(require.resolve(join(value, "package.json"))); } const config: StorybookConfig = { diff --git a/apps/frontend/src/components/Accordion/index.tsx b/apps/frontend/src/components/Accordion/index.tsx index 6da07d247..8fa71dac3 100644 --- a/apps/frontend/src/components/Accordion/index.tsx +++ b/apps/frontend/src/components/Accordion/index.tsx @@ -1,6 +1,7 @@ -import { AnimatePresence, motion } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { FC } from "react"; import { create } from "zustand"; +import { durations, easings } from "../../constants/animations"; import { cn } from "../../helpers/cn"; interface AccordionProps { @@ -44,6 +45,7 @@ const useAccordionStore = create(set => ({ const Accordion: FC = ({ children, className = "", defaultValue = [] }) => { const setValue = useAccordionStore(state => state.setValue); + const shouldReduceMotion = useReducedMotion(); if (defaultValue.length > 0) { setValue(defaultValue); @@ -53,8 +55,8 @@ const Accordion: FC = ({ children, className = "", defaultValue {children} @@ -63,21 +65,18 @@ const Accordion: FC = ({ children, className = "", defaultValue const AccordionItem: FC = ({ children, className = "", value }) => { const isOpen = useAccordionStore(state => state.value.includes(value)); + const shouldReduceMotion = useReducedMotion(); return ( - +
{children} - +
); }; @@ -85,61 +84,62 @@ const AccordionItem: FC = ({ children, className = "", value const AccordionTrigger: FC = ({ children, className = "", value }) => { const toggleValue = useAccordionStore(state => state.toggleValue); const isOpen = useAccordionStore(state => state.value.includes(value)); + const shouldReduceMotion = useReducedMotion(); return ( - +
toggleValue(value)} - whileHover={{ scale: 1.01 }} - whileTap={{ scale: 0.99 }} + whileHover={shouldReduceMotion ? undefined : { scale: 1.01 }} + whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }} >
- {children} + {children}
- +
); }; const AccordionContent: FC = ({ children, className = "", value }) => { const isOpen = useAccordionStore(state => state.value.includes(value)); + const shouldReduceMotion = useReducedMotion(); return ( - - {isOpen && ( - - - {children} - - - )} - +
+
+ + {isOpen && ( + + {children} + + )} + +
+
); }; diff --git a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx index 8dc3b5ef7..779aba973 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx @@ -37,7 +37,11 @@ export const AveniaKYBVerifyStep = ({

{t(titleKey)}

- Business Check + Business Check {!isVerificationStarted && (

diff --git a/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx b/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx index 4450045ec..d586eae82 100644 --- a/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx @@ -1,17 +1,11 @@ import { CNPJ_REGEX, CPF_REGEX, isValidCnpj, isValidCpf, RampDirection } from "@vortexfi/shared"; -import { AnimatePresence, type MotionProps, motion } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type { FC } from "react"; import { Trans, useTranslation } from "react-i18next"; +import { durations, easings } from "../../../constants/animations"; import { useRampDirection } from "../../../stores/rampDirectionStore"; import { AveniaField, AveniaFieldValidationPattern, StandardAveniaFieldOptions } from "../AveniaField"; -const containerAnimation: MotionProps = { - animate: { height: "auto", opacity: 1 }, - exit: { height: 0, opacity: 0 }, - initial: { height: 0, opacity: 0 }, - transition: { duration: 0.3 } -}; - const OFFRAMP_FIELDS = [ { id: StandardAveniaFieldOptions.TAX_ID, index: 0, label: "cpfOrCnpj" }, { id: StandardAveniaFieldOptions.PIX_ID, index: 1, label: "pixKey" } @@ -46,12 +40,18 @@ export const AveniaKycEligibilityFields: FC<{ isWalletAddressDisabled?: boolean const { t } = useTranslation(); const rampDirection = useRampDirection(); const isOnramp = rampDirection === RampDirection.BUY; + const shouldReduceMotion = useReducedMotion(); const FIELDS = isOnramp ? ONRAMP_FIELDS : OFFRAMP_FIELDS; return ( - + {FIELDS.map(field => ( { const { isExpanded, detailsId } = useCollapsibleCard(); + const shouldReduceMotion = useReducedMotion(); return ( - - {isExpanded && ( - - - {children} - - - )} - +

+
+ + {isExpanded && ( + + {children} + + )} + +
+
); }; diff --git a/apps/frontend/src/components/EmailForm/index.tsx b/apps/frontend/src/components/EmailForm/index.tsx index 26a433966..c730acc5f 100644 --- a/apps/frontend/src/components/EmailForm/index.tsx +++ b/apps/frontend/src/components/EmailForm/index.tsx @@ -56,7 +56,7 @@ export const EmailForm = ({ transactionId, transactionSuccess }: EmailFormProps)
{!isPending && !isSuccess && ( + ); +}; + +const CollapsibleCardWrapper = ({ defaultExpanded = false }: StoryArgs) => { + return ( +
+ + +
+

Transaction Summary

+

Click to view details

+
+ +
+ +
+
+ Amount + 100 USDC +
+
+ Fee + 0.5 USDC +
+
+ Network + Polkadot +
+
+ Estimated Time + ~2 minutes +
+
+
+
+
+ ); +}; + +const InteractiveDemo = () => { + const [isExpanded, setIsExpanded] = useState(false); + const [toggleCount, setToggleCount] = useState(0); + + const handleToggle = (expanded: boolean) => { + setIsExpanded(expanded); + setToggleCount(prev => prev + 1); + }; + + return ( +
+
+

State: {isExpanded ? "Expanded" : "Collapsed"}

+

Toggle count: {toggleCount}

+
+ + +
+

Quote Details

+

Your exchange rate and fees

+
+ +
+ +
+
+ You send + 500 BRL +
+
+ Exchange rate + 1 USDC = 5.02 BRL +
+
+ You receive + ~99.60 USDC +
+
+
+
+
+ ); +}; + +const MultipleCardsDemo = () => { + return ( +
+ + +
+

Step 1: Connect Wallet

+
+ +
+ +

Connect your Polkadot wallet to get started with the transaction.

+
+
+ + + +
+

Step 2: Enter Details

+
+ +
+ +

Enter your payment details including the amount and recipient information.

+
+
+ + + +
+

Step 3: Confirm

+
+ +
+ +

Review and confirm your transaction before submitting.

+
+
+
+ ); +}; + +const meta: Meta = { + argTypes: { + defaultExpanded: { + control: "boolean", + description: "Whether the card should be expanded by default" + } + }, + component: CollapsibleCardWrapper, + parameters: { + docs: { + description: { + component: + "A collapsible card component with smooth expand/collapse animations. Uses GPU-accelerated grid-template-rows animation instead of height for better performance. Supports reduced motion for accessibility." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/CollapsibleCard" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + defaultExpanded: false + }, + parameters: { + docs: { + description: { + story: "Default collapsed state. Click the toggle button to expand and see the details." + } + } + }, + render: CollapsibleCardWrapper +}; + +export const Expanded: Story = { + args: { + defaultExpanded: true + }, + parameters: { + docs: { + description: { + story: "Card expanded by default showing all details." + } + } + }, + render: CollapsibleCardWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo with state tracking. Watch the state change as you toggle the card." + } + } + }, + render: InteractiveDemo +}; + +export const MultipleCards: Story = { + parameters: { + docs: { + description: { + story: "Multiple collapsible cards demonstrating independent expand/collapse behavior." + } + } + }, + render: MultipleCardsDemo +}; + +export const ReducedMotion: Story = { + args: { + defaultExpanded: false + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support by enabling 'prefers-reduced-motion: reduce' in browser DevTools. The expand/collapse animation will be instant." + } + } + }, + render: CollapsibleCardWrapper +}; diff --git a/apps/frontend/src/stories/KycLevel2Toggle.stories.tsx b/apps/frontend/src/stories/KycLevel2Toggle.stories.tsx new file mode 100644 index 000000000..a87969263 --- /dev/null +++ b/apps/frontend/src/stories/KycLevel2Toggle.stories.tsx @@ -0,0 +1,182 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AveniaDocumentType } from "@vortexfi/shared"; +import { useState } from "react"; +import { KycLevel2Toggle } from "../components/KycLevel2Toggle"; + +interface StoryArgs { + activeDocType?: AveniaDocumentType; +} + +const KycLevel2ToggleWrapper = ({ activeDocType = AveniaDocumentType.ID }: StoryArgs) => { + const [docType, setDocType] = useState(activeDocType); + + return ( +
+ +
+ ); +}; + +const InteractiveDemo = () => { + const [docType, setDocType] = useState(AveniaDocumentType.ID); + const [toggleCount, setToggleCount] = useState(0); + + const handleToggle = (newDocType: AveniaDocumentType) => { + setDocType(newDocType); + setToggleCount(prev => prev + 1); + }; + + return ( +
+
+

+ Selected document: {docType === AveniaDocumentType.ID ? "RG (ID Card)" : "CNH (Driver's License)"} +

+

Toggle count: {toggleCount}

+
+ +
+ ); +}; + +interface DocumentInfo { + description: string; + icon: string; + instructions: string[]; + title: string; +} + +const KycFlowDemo = () => { + const [docType, setDocType] = useState(AveniaDocumentType.ID); + + const documentInfo: Record = { + [AveniaDocumentType.ID]: { + description: "Brazilian national identity card (Registro Geral)", + icon: "RG", + instructions: ["Front side of the document", "Back side of the document", "Must be valid and not expired"], + title: "Identity Card (RG)" + }, + [AveniaDocumentType.DRIVERS_LICENSE]: { + description: "Brazilian driver's license (Carteira Nacional de Habilitacao)", + icon: "CNH", + instructions: ["Front side of the license", "Back side of the license", "Must be valid and not expired"], + title: "Driver's License (CNH)" + } + }; + + const info = documentInfo[docType]; + + return ( +
+

Document Verification

+

Select your document type for KYC Level 2 verification

+ + + +
+

{info.title}

+

{info.description}

+

Required photos:

+
    + {info.instructions.map((instruction, index) => ( +
  • {instruction}
  • + ))} +
+
+ + +
+ ); +}; + +const meta: Meta = { + argTypes: { + activeDocType: { + control: "select", + description: "Currently selected document type", + options: [AveniaDocumentType.ID, AveniaDocumentType.DRIVERS_LICENSE] + } + }, + component: KycLevel2ToggleWrapper, + parameters: { + docs: { + description: { + component: + "A toggle component for selecting between Brazilian document types (RG or CNH) during KYC Level 2 verification. Features smooth spring animation for the indicator and supports reduced motion preferences." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/KycLevel2Toggle" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + activeDocType: AveniaDocumentType.ID + }, + parameters: { + docs: { + description: { + story: "Default toggle with RG (Identity Card) selected." + } + } + }, + render: KycLevel2ToggleWrapper +}; + +export const DriversLicenseSelected: Story = { + args: { + activeDocType: AveniaDocumentType.DRIVERS_LICENSE + }, + parameters: { + docs: { + description: { + story: "Toggle with CNH (Driver's License) selected." + } + } + }, + render: KycLevel2ToggleWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo with state tracking. Watch the indicator smoothly animate between options." + } + } + }, + render: InteractiveDemo +}; + +export const KycFlow: Story = { + parameters: { + docs: { + description: { + story: "Real-world example showing the toggle integrated into a KYC verification flow." + } + } + }, + render: KycFlowDemo +}; + +export const ReducedMotion: Story = { + args: { + activeDocType: AveniaDocumentType.ID + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions." + } + } + }, + render: KycLevel2ToggleWrapper +}; diff --git a/apps/frontend/src/stories/Menu.stories.tsx b/apps/frontend/src/stories/Menu.stories.tsx new file mode 100644 index 000000000..e2ff8ae46 --- /dev/null +++ b/apps/frontend/src/stories/Menu.stories.tsx @@ -0,0 +1,239 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { Menu, MenuAnimationDirection } from "../components/menus/Menu"; + +interface StoryArgs { + animationDirection?: MenuAnimationDirection; + title?: string; +} + +const MenuWrapper = ({ animationDirection = MenuAnimationDirection.RIGHT, title = "Menu" }: StoryArgs) => { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+
+ Main Content + +
+ +
+

This is the main content area. The menu will slide over this content.

+
+ + setIsOpen(false)} title={title}> +
+ + + +
+
+
+ ); +}; + +const DirectionDemo = () => { + const [direction, setDirection] = useState(MenuAnimationDirection.RIGHT); + const [isOpen, setIsOpen] = useState(false); + + return ( +
+
+ + +
+ +
+
+ Main Content + +
+ +
+

Current direction: {direction}

+
+ + setIsOpen(false)} title="Settings"> +
+
+ + +
+
+ + +
+
+
+
+
+ ); +}; + +const TokenSelectionDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const tokens = [ + { balance: "1,234.56", name: "USDC", network: "Polkadot" }, + { balance: "567.89", name: "USDT", network: "Ethereum" }, + { balance: "100.00", name: "BRZ", network: "Stellar" } + ]; + + return ( +
+
+ +
+ + setIsOpen(false)} + title="Select Token" + > +
+ {tokens.map(token => ( + + ))} +
+
+
+ ); +}; + +const meta: Meta = { + argTypes: { + animationDirection: { + control: "select", + description: "Direction from which the menu slides in", + options: [MenuAnimationDirection.RIGHT, MenuAnimationDirection.TOP] + }, + title: { + control: "text", + description: "Title displayed in the menu header" + } + }, + component: MenuWrapper, + parameters: { + docs: { + description: { + component: + "A sliding overlay menu component with directional animations. Supports slide-in from right or top with smooth easeOut curves. Features escape key support and reduced motion accessibility." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/Menu" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + animationDirection: MenuAnimationDirection.RIGHT, + title: "Menu" + }, + parameters: { + docs: { + description: { + story: "Default menu sliding in from the right. Press Escape or click the close button to dismiss." + } + } + }, + render: MenuWrapper +}; + +export const FromTop: Story = { + args: { + animationDirection: MenuAnimationDirection.TOP, + title: "Dropdown Menu" + }, + parameters: { + docs: { + description: { + story: "Menu sliding in from the top, useful for dropdown-style menus." + } + } + }, + render: MenuWrapper +}; + +export const DirectionComparison: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo comparing different animation directions. Select a direction and open the menu." + } + } + }, + render: DirectionDemo +}; + +export const TokenSelection: Story = { + parameters: { + docs: { + description: { + story: "Real-world example showing the menu used for token selection in a swap interface." + } + } + }, + render: TokenSelectionDemo +}; + +export const ReducedMotion: Story = { + args: { + animationDirection: MenuAnimationDirection.RIGHT, + title: "Accessible Menu" + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions." + } + } + }, + render: MenuWrapper +}; diff --git a/apps/frontend/src/stories/MobileMenu.stories.tsx b/apps/frontend/src/stories/MobileMenu.stories.tsx new file mode 100644 index 000000000..a16f72aa9 --- /dev/null +++ b/apps/frontend/src/stories/MobileMenu.stories.tsx @@ -0,0 +1,197 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AnimatePresence } from "motion/react"; +import { useState } from "react"; +import { MobileMenu } from "../components/Navbar/MobileMenu"; + +interface StoryArgs { + isOpen?: boolean; +} + +const MobileMenuWrapper = ({ isOpen = true }: StoryArgs) => { + const [menuOpen, setMenuOpen] = useState(isOpen); + + const handleMenuItemClick = () => { + setMenuOpen(false); + }; + + return ( +
+ {/* Mock navbar header */} +
+ Vortex + +
+ + {/* Mobile menu with animation */} + {menuOpen && } +
+ ); +}; + +const InteractiveDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const [clickedItem, setClickedItem] = useState(null); + + const handleMenuItemClick = () => { + setClickedItem("Menu item clicked!"); + setIsOpen(false); + setTimeout(() => setClickedItem(null), 2000); + }; + + return ( +
+
+

Menu state: {isOpen ? "Open" : "Closed"}

+ {clickedItem &&

{clickedItem}

} +
+ +
+
+ Vortex + +
+ + {isOpen && } +
+
+ ); +}; + +const AnimationShowcaseDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const [speed, setSpeed] = useState<"normal" | "slow">("normal"); + + return ( +
+
+ + +
+

+ To see the animation in slow motion, open DevTools → Rendering → check "Emulate CSS media feature + prefers-reduced-motion" +

+ +
+
+ Vortex + +
+ + {isOpen && setIsOpen(false)} />} +
+
+ ); +}; + +const meta: Meta = { + argTypes: { + isOpen: { + control: "boolean", + description: "Whether the mobile menu is initially open" + } + }, + component: MobileMenuWrapper, + parameters: { + docs: { + description: { + component: + "Mobile navigation menu with staggered entrance animations. Features smooth slide-in animations for menu items and respects reduced motion preferences for accessibility. Uses easeOut curves for responsive feel." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/MobileMenu" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isOpen: true + }, + parameters: { + docs: { + description: { + story: "Mobile menu in its open state showing navigation links and call-to-action button." + } + } + }, + render: MobileMenuWrapper +}; + +export const Closed: Story = { + args: { + isOpen: false + }, + parameters: { + docs: { + description: { + story: "Mobile menu in closed state. Click the button to open and see the entrance animation." + } + } + }, + render: MobileMenuWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo with state tracking. Toggle the menu to see smooth entrance/exit animations." + } + } + }, + render: InteractiveDemo +}; + +export const AnimationShowcase: Story = { + parameters: { + docs: { + description: { + story: + "Showcase the staggered animation effect. Open the menu multiple times to observe how menu items animate in sequence." + } + } + }, + render: AnimationShowcaseDemo +}; + +export const ReducedMotion: Story = { + args: { + isOpen: false + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions instead of animations." + } + } + }, + render: MobileMenuWrapper +}; diff --git a/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx b/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx new file mode 100644 index 000000000..96fe25b4e --- /dev/null +++ b/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx @@ -0,0 +1,287 @@ +import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { SelectionButtonMotion } from "../components/TokenSelection/NetworkSelectionList/animations/SelectionButtonMotion"; +import { SelectionChevronMotion } from "../components/TokenSelection/NetworkSelectionList/animations/SelectionChevronMotion"; +import { SelectionDropdownMotion } from "../components/TokenSelection/NetworkSelectionList/animations/SelectionDropdownMotion"; + +const networks = [ + { icon: "polkadot.svg", id: "polkadot", name: "Polkadot" }, + { icon: "ethereum.svg", id: "ethereum", name: "Ethereum" }, + { icon: "stellar.svg", id: "stellar", name: "Stellar" }, + { icon: "moonbeam.svg", id: "moonbeam", name: "Moonbeam" } +]; + +const SelectionDropdownDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const [selected, setSelected] = useState(networks[0]); + + return ( +
+ + + +
+ {networks.map(network => ( + + ))} +
+
+
+ ); +}; + +const SelectionButtonDemo = () => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+
+ setIsExpanded(!isExpanded)} + > + {isExpanded ? "Full width button - click to collapse" : "Click"} + + + {!isExpanded && ( +
+ Other content +
+ )} +
+ +

The button animates between 10% and 100% width. Click to toggle.

+
+ ); +}; + +const SelectionChevronDemo = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + +

Chevron rotates 180° when {isOpen ? "open" : "closed"}

+
+ ); +}; + +const NetworkDropdownDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const [selectedNetwork, setSelectedNetwork] = useState(networks[0]); + + return ( +
+
+

Select Network

+ +
+ + + +
+ {networks.map(network => ( + + ))} +
+
+
+
+
+ ); +}; + +const AllAnimationsDemo = () => { + const [isExpanded, setIsExpanded] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + + return ( +
+

All Selection Animations

+ +
+

1. SelectionButtonMotion

+ setIsExpanded(!isExpanded)} + > + {isExpanded ? "Expanded - Click to Collapse" : "Expand"} + +
+ +
+

2. SelectionChevronMotion

+
+ +
+
+ +
+

3. SelectionDropdownMotion

+ +
+

Dropdown Content

+

This content smoothly expands using grid-template-rows animation.

+
+
+
+
+ ); +}; + +const meta: Meta = { + parameters: { + docs: { + description: { + component: + "A collection of animation components used in the network/token selection interface. Includes:\n\n" + + "- **SelectionDropdownMotion**: Smooth expand/collapse using GPU-accelerated grid-template-rows\n" + + "- **SelectionButtonMotion**: Width animation with easeOut curve\n" + + "- **SelectionChevronMotion**: 180° rotation animation for dropdown indicators\n\n" + + "All components support reduced motion preferences for accessibility." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/NetworkSelection" +}; + +export default meta; +type Story = StoryObj; + +export const Dropdown: Story = { + parameters: { + docs: { + description: { + story: "SelectionDropdownMotion with SelectionChevronMotion combined for a complete dropdown experience." + } + } + }, + render: SelectionDropdownDemo +}; + +export const Button: Story = { + parameters: { + docs: { + description: { + story: "SelectionButtonMotion demonstrates width animation between collapsed (10%) and expanded (100%) states." + } + } + }, + render: SelectionButtonDemo +}; + +export const Chevron: Story = { + parameters: { + docs: { + description: { + story: "SelectionChevronMotion provides smooth 180° rotation for dropdown indicators." + } + } + }, + render: SelectionChevronDemo +}; + +export const NetworkDropdown: Story = { + parameters: { + docs: { + description: { + story: "Real-world example showing all three animation components working together in a network selector." + } + } + }, + render: NetworkDropdownDemo +}; + +export const AllAnimations: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo showcasing all three animation components side by side." + } + } + }, + render: AllAnimationsDemo +}; + +export const ReducedMotion: Story = { + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions instead of animations." + } + } + }, + render: AllAnimationsDemo +}; diff --git a/apps/frontend/src/stories/RampToggle.stories.tsx b/apps/frontend/src/stories/RampToggle.stories.tsx new file mode 100644 index 000000000..72368ac04 --- /dev/null +++ b/apps/frontend/src/stories/RampToggle.stories.tsx @@ -0,0 +1,182 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { RampDirection } from "@vortexfi/shared"; +import { useState } from "react"; +import { RampToggle } from "../components/RampToggle"; + +interface StoryArgs { + activeDirection?: RampDirection; +} + +const RampToggleWrapper = ({ activeDirection = RampDirection.BUY }: StoryArgs) => { + const [direction, setDirection] = useState(activeDirection); + + return ( +
+ +
+ ); +}; + +const InteractiveDemo = () => { + const [direction, setDirection] = useState(RampDirection.BUY); + const [toggleCount, setToggleCount] = useState(0); + + const handleToggle = (newDirection: RampDirection) => { + setDirection(newDirection); + setToggleCount(prev => prev + 1); + }; + + return ( +
+
+

Current direction: {direction === RampDirection.BUY ? "Buy" : "Sell"}

+

Toggle count: {toggleCount}

+
+ +
+ {direction === RampDirection.BUY ? ( +
+

Buy Crypto

+

Convert fiat currency to cryptocurrency

+
+ ) : ( +
+

Sell Crypto

+

Convert cryptocurrency to fiat currency

+
+ )} +
+
+ ); +}; + +const SwapInterfaceDemo = () => { + const [direction, setDirection] = useState(RampDirection.BUY); + const [amount, setAmount] = useState("100"); + + return ( +
+ + +
+
+ +
+ setAmount(e.target.value)} + type="text" + value={amount} + /> + {direction === RampDirection.BUY ? "BRL" : "USDC"} +
+
+ +
+ +
+ {(parseFloat(amount || "0") / 5).toFixed(2)} + {direction === RampDirection.BUY ? "USDC" : "BRL"} +
+
+ + +
+
+ ); +}; + +const meta: Meta = { + argTypes: { + activeDirection: { + control: "select", + description: "Currently active ramp direction", + options: [RampDirection.BUY, RampDirection.SELL] + } + }, + component: RampToggleWrapper, + parameters: { + docs: { + description: { + component: + "A toggle component for switching between Buy and Sell modes in the ramp interface. Features a smooth spring animation for the indicator and respects reduced motion preferences." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/RampToggle" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + activeDirection: RampDirection.BUY + }, + parameters: { + docs: { + description: { + story: "Default toggle with Buy selected. Click to switch between Buy and Sell." + } + } + }, + render: RampToggleWrapper +}; + +export const SellActive: Story = { + args: { + activeDirection: RampDirection.SELL + }, + parameters: { + docs: { + description: { + story: "Toggle with Sell selected." + } + } + }, + render: RampToggleWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo showing how the toggle updates the UI based on the selected direction." + } + } + }, + render: InteractiveDemo +}; + +export const SwapInterface: Story = { + parameters: { + docs: { + description: { + story: + "Real-world example showing the toggle integrated into a swap interface. Notice how the input/output labels and button text change based on the direction." + } + } + }, + render: SwapInterfaceDemo +}; + +export const ReducedMotion: Story = { + args: { + activeDirection: RampDirection.BUY + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions instead of the spring animation." + } + } + }, + render: RampToggleWrapper +}; diff --git a/apps/frontend/src/stories/TermsAndConditions.stories.tsx b/apps/frontend/src/stories/TermsAndConditions.stories.tsx new file mode 100644 index 000000000..c09f2948d --- /dev/null +++ b/apps/frontend/src/stories/TermsAndConditions.stories.tsx @@ -0,0 +1,289 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { TermsAndConditions } from "../components/TermsAndConditions"; + +interface StoryArgs { + termsChecked?: boolean; + termsAccepted?: boolean; + termsError?: boolean; +} + +const TermsAndConditionsWrapper = ({ termsChecked = false, termsAccepted = false, termsError = false }: StoryArgs) => { + const [checked, setChecked] = useState(termsChecked); + const [error, setError] = useState(termsError); + const [accepted, setAccepted] = useState(termsAccepted); + + return ( +
+ setChecked(!checked)} + /> + {!accepted && ( +
+ + {!checked && ( + + )} +
+ )} +
+ ); +}; + +const InteractiveDemo = () => { + const [checked, setChecked] = useState(false); + const [error, setError] = useState(false); + const [accepted, setAccepted] = useState(false); + + const handleContinue = () => { + if (!checked) { + setError(true); + return; + } + setAccepted(true); + }; + + const handleReset = () => { + setChecked(false); + setError(false); + setAccepted(false); + }; + + return ( +
+
+

+ State: {accepted ? "Accepted" : checked ? "Checked" : error ? "Error" : "Unchecked"} +

+
+ + { + setChecked(!checked); + setError(false); + }} + /> + + {accepted ? ( +
+

Terms Accepted!

+ +
+ ) : ( + + )} +
+ ); +}; + +const CheckoutFlowDemo = () => { + const [checked, setChecked] = useState(false); + const [error, setError] = useState(false); + const [accepted, setAccepted] = useState(false); + + return ( +
+

Complete Your Order

+ +
+
+ Amount + 100 USDC +
+
+ Fee + 0.50 USDC +
+
+ Total + 100.50 USDC +
+
+ + { + setChecked(!checked); + setError(false); + }} + /> + + {accepted ? ( +
+

Order Confirmed!

+
+ ) : ( + + )} +
+ ); +}; + +const meta: Meta = { + argTypes: { + termsAccepted: { + control: "boolean", + description: "Whether the terms have been accepted (hides the checkbox)" + }, + termsChecked: { + control: "boolean", + description: "Whether the checkbox is checked" + }, + termsError: { + control: "boolean", + description: "Whether to show the error state" + } + }, + component: TermsAndConditionsWrapper, + parameters: { + docs: { + description: { + component: + "A terms and conditions checkbox component with animated error states and fade-out on acceptance. Features a subtle scale animation on error and supports reduced motion preferences." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/TermsAndConditions" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + termsAccepted: false, + termsChecked: false, + termsError: false + }, + parameters: { + docs: { + description: { + story: "Default unchecked state. Check the box before continuing." + } + } + }, + render: TermsAndConditionsWrapper +}; + +export const Checked: Story = { + args: { + termsAccepted: false, + termsChecked: true, + termsError: false + }, + parameters: { + docs: { + description: { + story: "Checkbox in checked state, ready to continue." + } + } + }, + render: TermsAndConditionsWrapper +}; + +export const Error: Story = { + args: { + termsAccepted: false, + termsChecked: false, + termsError: true + }, + parameters: { + docs: { + description: { + story: "Error state shown when user tries to continue without accepting terms." + } + } + }, + render: TermsAndConditionsWrapper +}; + +export const Accepted: Story = { + args: { + termsAccepted: true, + termsChecked: true, + termsError: false + }, + parameters: { + docs: { + description: { + story: "Accepted state - the checkbox fades out with a scale animation." + } + } + }, + render: TermsAndConditionsWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo showing the full flow: unchecked -> error -> checked -> accepted." + } + } + }, + render: InteractiveDemo +}; + +export const CheckoutFlow: Story = { + parameters: { + docs: { + description: { + story: "Real-world example showing the terms checkbox in a checkout/confirmation flow." + } + } + }, + render: CheckoutFlowDemo +}; + +export const ReducedMotion: Story = { + args: { + termsAccepted: false, + termsChecked: false, + termsError: false + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' to see instant transitions instead of animations." + } + } + }, + render: TermsAndConditionsWrapper +}; diff --git a/packages/shared/src/tokens/index.ts b/packages/shared/src/tokens/index.ts index 3359fa73f..16bf03971 100644 --- a/packages/shared/src/tokens/index.ts +++ b/packages/shared/src/tokens/index.ts @@ -8,6 +8,8 @@ export * from "./constants/misc"; // Constants // Configurations export * from "./evm/config"; +// Dynamic tokens - must be exported AFTER all dependencies (config, pendulum/config, etc.) +export * from "./evm/dynamicEvmTokens"; export * from "./moonbeam/config"; export * from "./pendulum/config"; export * from "./stellar/config"; @@ -24,5 +26,3 @@ export * from "./utils/helpers"; export * from "./utils/normalization"; // Utils export * from "./utils/typeGuards"; -// Dynamic tokens - must be exported AFTER all dependencies (config, pendulum/config, etc.) -export * from "./evm/dynamicEvmTokens";