Add POI feature and imporve the overall backend implementation

This commit is contained in:
Urtzi Alfaro
2025-11-12 15:34:10 +01:00
parent e8096cd979
commit 5783c7ed05
173 changed files with 16862 additions and 9078 deletions

View File

@@ -0,0 +1,56 @@
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1 @@
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './Accordion';

View File

@@ -0,0 +1,191 @@
/**
* Address Autocomplete Component
*
* Provides autocomplete functionality for address input with geocoding
*/
import React, { useRef, useEffect, useState } from 'react';
import { MapPin, Loader2, X, Check } from 'lucide-react';
import { Input, Button, Card, CardBody } from '@/components/ui';
import { useAddressAutocomplete } from '@/hooks/useAddressAutocomplete';
import { AddressResult } from '@/services/api/geocodingApi';
interface AddressAutocompleteProps {
value?: string;
placeholder?: string;
onAddressSelect?: (address: AddressResult) => void;
onCoordinatesChange?: (lat: number, lon: number) => void;
className?: string;
disabled?: boolean;
countryCode?: string;
required?: boolean;
}
export const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
value,
placeholder = 'Enter bakery address...',
onAddressSelect,
onCoordinatesChange,
className = '',
disabled = false,
countryCode = 'es',
required = false
}) => {
const {
query,
setQuery,
results,
isLoading,
error,
selectedAddress,
selectAddress,
clearSelection
} = useAddressAutocomplete({ countryCode });
const [showResults, setShowResults] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
// Initialize query from value prop
useEffect(() => {
if (value && !query) {
setQuery(value);
}
}, [value, query, setQuery]);
// Close results dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setShowResults(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setQuery(newQuery);
setShowResults(true);
};
const handleSelectAddress = (address: AddressResult) => {
selectAddress(address);
setShowResults(false);
// Notify parent components
if (onAddressSelect) {
onAddressSelect(address);
}
if (onCoordinatesChange) {
onCoordinatesChange(address.lat, address.lon);
}
};
const handleClear = () => {
clearSelection();
setShowResults(false);
};
const handleInputFocus = () => {
if (results.length > 0) {
setShowResults(true);
}
};
return (
<div ref={wrapperRef} className={`relative ${className}`}>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
value={query}
onChange={handleInputChange}
onFocus={handleInputFocus}
placeholder={placeholder}
disabled={disabled}
required={required}
className={`pl-10 pr-10 ${selectedAddress ? 'border-green-500' : ''}`}
/>
<div className="absolute right-3 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
{isLoading && (
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
)}
{selectedAddress && !isLoading && (
<Check className="h-4 w-4 text-green-600" />
)}
{query && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
className="h-6 w-6 p-0 hover:bg-gray-100"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* Error message */}
{error && (
<div className="mt-1 text-sm text-red-600">
{error}
</div>
)}
{/* Results dropdown */}
{showResults && results.length > 0 && (
<Card className="absolute z-50 w-full mt-1 max-h-80 overflow-y-auto shadow-lg">
<CardBody className="p-0">
<div className="divide-y divide-gray-100">
{results.map((result) => (
<button
key={result.place_id}
type="button"
onClick={() => handleSelectAddress(result)}
className="w-full text-left px-4 py-3 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none transition-colors"
>
<div className="flex items-start gap-3">
<MapPin className="h-4 w-4 text-blue-600 mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{result.address.road && result.address.house_number
? `${result.address.road}, ${result.address.house_number}`
: result.address.road || result.display_name}
</div>
<div className="text-xs text-gray-600 truncate mt-0.5">
{result.address.city || result.address.municipality || result.address.suburb}
{result.address.postcode && `, ${result.address.postcode}`}
</div>
<div className="text-xs text-gray-400 mt-1">
{result.lat.toFixed(6)}, {result.lon.toFixed(6)}
</div>
</div>
</div>
</button>
))}
</div>
</CardBody>
</Card>
)}
{/* No results message */}
{showResults && !isLoading && query.length >= 3 && results.length === 0 && !error && (
<Card className="absolute z-50 w-full mt-1 shadow-lg">
<CardBody className="p-4">
<div className="text-sm text-gray-600 text-center">
No addresses found for "{query}"
</div>
</CardBody>
</Card>
)}
</div>
);
};

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
warning:
'border-amber-500/50 text-amber-700 dark:border-amber-500 [&>svg]:text-amber-500',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1 @@
export { Alert, AlertTitle, AlertDescription } from './Alert';

View File

@@ -0,0 +1,101 @@
import React, { useEffect, useRef, useState } from 'react';
import { motion, useInView, useSpring, useTransform } from 'framer-motion';
export interface AnimatedCounterProps {
/** The target value to count to */
value: number;
/** Duration of the animation in seconds */
duration?: number;
/** Number of decimal places to display */
decimals?: number;
/** Prefix to display before the number (e.g., "€", "$") */
prefix?: string;
/** Suffix to display after the number (e.g., "%", "/mes") */
suffix?: string;
/** Additional CSS classes */
className?: string;
/** Delay before animation starts (in seconds) */
delay?: number;
/** Whether to animate on mount or when in view */
animateOnMount?: boolean;
}
/**
* AnimatedCounter - Animates numbers counting up from 0 to target value
*
* Features:
* - Smooth spring-based animation
* - Configurable duration and delay
* - Support for decimals, prefix, and suffix
* - Triggers animation when scrolling into view
* - Accessible with proper number formatting
*
* @example
* <AnimatedCounter value={2000} prefix="€" suffix="/mes" />
* <AnimatedCounter value={92} suffix="%" decimals={0} />
*/
export const AnimatedCounter: React.FC<AnimatedCounterProps> = ({
value,
duration = 2,
decimals = 0,
prefix = '',
suffix = '',
className = '',
delay = 0,
animateOnMount = false,
}) => {
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true, amount: 0.5 });
const [hasAnimated, setHasAnimated] = useState(false);
const shouldAnimate = animateOnMount || isInView;
// Spring animation for smooth counting
const spring = useSpring(0, {
damping: 30,
stiffness: 50,
duration: duration * 1000,
});
const display = useTransform(spring, (current) =>
current.toFixed(decimals)
);
useEffect(() => {
if (shouldAnimate && !hasAnimated) {
const timer = setTimeout(() => {
spring.set(value);
setHasAnimated(true);
}, delay * 1000);
return () => clearTimeout(timer);
}
}, [shouldAnimate, hasAnimated, value, spring, delay]);
const [displayValue, setDisplayValue] = useState('0');
useEffect(() => {
const unsubscribe = display.on('change', (latest) => {
setDisplayValue(latest);
});
return unsubscribe;
}, [display]);
return (
<motion.span
ref={ref}
className={className}
initial={{ opacity: 0, y: 20 }}
animate={shouldAnimate ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.5, delay: delay }}
aria-live="polite"
aria-atomic="true"
>
{prefix}
{displayValue}
{suffix}
</motion.span>
);
};
export default AnimatedCounter;

View File

@@ -25,6 +25,14 @@ export interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
justify?: 'start' | 'end' | 'center' | 'between' | 'around' | 'evenly';
}
export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
}
export interface CardContentProps extends HTMLAttributes<HTMLDivElement> {
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
}
const Card = forwardRef<HTMLDivElement, CardProps>(({
variant = 'elevated',
padding = 'md',
@@ -228,10 +236,87 @@ const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(({
);
});
const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(({
as: Component = 'h3',
className,
children,
...props
}, ref) => {
const classes = clsx(
'text-lg font-semibold leading-none tracking-tight',
className
);
return (
<Component
ref={ref}
className={classes}
{...props}
>
{children}
</Component>
);
});
const CardContent = forwardRef<HTMLDivElement, CardContentProps>(({
padding = 'md',
className,
children,
...props
}, ref) => {
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
xl: 'p-8',
};
const classes = clsx(
paddingClasses[padding],
'flex-1',
className
);
return (
<div
ref={ref}
className={classes}
{...props}
>
{children}
</div>
);
});
const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(({
className,
children,
...props
}, ref) => {
const classes = clsx(
'text-sm text-[var(--text-secondary)]',
className
);
return (
<p
ref={ref}
className={classes}
{...props}
>
{children}
</p>
);
});
Card.displayName = 'Card';
CardHeader.displayName = 'CardHeader';
CardBody.displayName = 'CardBody';
CardFooter.displayName = 'CardFooter';
CardTitle.displayName = 'CardTitle';
CardDescription.displayName = 'CardDescription';
CardContent.displayName = 'CardContent';
export default Card;
export { CardHeader, CardBody, CardFooter };
export { CardHeader, CardBody, CardFooter, CardContent, CardTitle, CardDescription };

View File

@@ -1,3 +1,3 @@
export { default } from './Card';
export { default as Card, CardHeader, CardBody, CardFooter } from './Card';
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps } from './Card';
export { default as Card, CardHeader, CardBody, CardFooter, CardContent, CardTitle, CardDescription } from './Card';
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps, CardContentProps, CardTitleProps } from './Card';

View File

@@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, Search } from 'lucide-react';
export interface FAQItem {
id: string;
question: string;
answer: string;
category?: string;
}
export interface FAQAccordionProps {
items: FAQItem[];
allowMultiple?: boolean;
showSearch?: boolean;
defaultOpen?: string[];
className?: string;
}
/**
* FAQAccordion - Collapsible FAQ component with search
*
* Features:
* - Smooth expand/collapse animations
* - Optional search functionality
* - Category filtering
* - Single or multiple open items
* - Fully accessible (keyboard navigation, ARIA)
*
* @example
* <FAQAccordion
* items={[
* { id: '1', question: '¿Cuántos datos necesito?', answer: '6-12 meses de datos de ventas.' },
* { id: '2', question: '¿Por qué necesito dar mi tarjeta?', answer: 'Para continuar automáticamente...' }
* ]}
* showSearch
* allowMultiple={false}
* />
*/
export const FAQAccordion: React.FC<FAQAccordionProps> = ({
items,
allowMultiple = false,
showSearch = false,
defaultOpen = [],
className = '',
}) => {
const [openItems, setOpenItems] = useState<string[]>(defaultOpen);
const [searchQuery, setSearchQuery] = useState('');
const toggleItem = (id: string) => {
if (allowMultiple) {
setOpenItems((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
);
} else {
setOpenItems((prev) => (prev.includes(id) ? [] : [id]));
}
};
const filteredItems = items.filter((item) => {
const query = searchQuery.toLowerCase();
return (
item.question.toLowerCase().includes(query) ||
item.answer.toLowerCase().includes(query)
);
});
return (
<div className={className}>
{/* Search */}
{showSearch && (
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-tertiary)]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar preguntas..."
className="w-full pl-10 pr-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent"
/>
</div>
</div>
)}
{/* FAQ Items */}
<div className="space-y-4">
{filteredItems.length > 0 ? (
filteredItems.map((item) => {
const isOpen = openItems.includes(item.id);
return (
<div
key={item.id}
className="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-primary)] overflow-hidden hover:border-[var(--color-primary)] transition-colors"
>
<button
onClick={() => toggleItem(item.id)}
className="w-full px-6 py-4 flex items-center justify-between gap-4 text-left hover:bg-[var(--bg-primary)] transition-colors"
aria-expanded={isOpen}
aria-controls={`faq-answer-${item.id}`}
>
<span className="text-lg font-bold text-[var(--text-primary)]">
{item.question}
</span>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
className="flex-shrink-0"
>
<ChevronDown className="w-5 h-5 text-[var(--text-secondary)]" />
</motion.div>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
id={`faq-answer-${item.id}`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] },
opacity: { duration: 0.2 },
}}
className="overflow-hidden"
>
<div className="px-6 pb-4 text-[var(--text-secondary)] leading-relaxed">
{item.answer}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})
) : (
<div className="text-center py-8 text-[var(--text-secondary)]">
No se encontraron preguntas que coincidan con tu búsqueda.
</div>
)}
</div>
{/* Results count */}
{showSearch && searchQuery && (
<div className="mt-4 text-sm text-[var(--text-tertiary)] text-center">
{filteredItems.length} {filteredItems.length === 1 ? 'resultado' : 'resultados'}
</div>
)}
</div>
);
};
export default FAQAccordion;

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import { Button } from './Button';
export interface FloatingCTAProps {
/** Text to display in the CTA button */
text: string;
/** Click handler for the CTA button */
onClick: () => void;
/** Icon to display (optional) */
icon?: React.ReactNode;
/** Position of the floating CTA */
position?: 'bottom-right' | 'bottom-left' | 'bottom-center';
/** Minimum scroll position (in pixels) to show the CTA */
showAfterScroll?: number;
/** Allow user to dismiss the CTA */
dismissible?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* FloatingCTA - Persistent call-to-action button that appears on scroll
*
* Features:
* - Appears after scrolling past a threshold
* - Smooth slide-in/slide-out animation
* - Dismissible with close button
* - Configurable position
* - Mobile-responsive
*
* @example
* <FloatingCTA
* text="Solicitar Demo"
* onClick={() => navigate('/demo')}
* position="bottom-right"
* showAfterScroll={500}
* dismissible
* />
*/
export const FloatingCTA: React.FC<FloatingCTAProps> = ({
text,
onClick,
icon,
position = 'bottom-right',
showAfterScroll = 400,
dismissible = true,
className = '',
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isDismissed, setIsDismissed] = useState(false);
useEffect(() => {
const handleScroll = () => {
const scrollPosition = window.scrollY;
setIsVisible(scrollPosition > showAfterScroll && !isDismissed);
};
window.addEventListener('scroll', handleScroll);
handleScroll(); // Check initial position
return () => window.removeEventListener('scroll', handleScroll);
}, [showAfterScroll, isDismissed]);
const handleDismiss = (e: React.MouseEvent) => {
e.stopPropagation();
setIsDismissed(true);
};
const positionClasses = {
'bottom-right': 'bottom-6 right-6',
'bottom-left': 'bottom-6 left-6',
'bottom-center': 'bottom-6 left-1/2 -translate-x-1/2',
};
const slideVariants = {
'bottom-right': {
hidden: { x: 100, opacity: 0 },
visible: { x: 0, opacity: 1 },
exit: { x: 100, opacity: 0 },
},
'bottom-left': {
hidden: { x: -100, opacity: 0 },
visible: { x: 0, opacity: 1 },
exit: { x: -100, opacity: 0 },
},
'bottom-center': {
hidden: { y: 100, opacity: 0 },
visible: { y: 0, opacity: 1 },
exit: { y: 100, opacity: 0 },
},
};
return (
<AnimatePresence>
{isVisible && (
<motion.div
className={`fixed ${positionClasses[position]} z-40 ${className}`}
initial="hidden"
animate="visible"
exit="exit"
variants={slideVariants[position]}
transition={{ type: 'spring', stiffness: 100, damping: 20 }}
>
<div className="relative">
<Button
onClick={onClick}
size="lg"
className="shadow-2xl hover:shadow-3xl transition-shadow duration-300 bg-gradient-to-r from-[var(--color-primary)] to-orange-600 hover:from-[var(--color-primary-dark)] hover:to-orange-700 text-white font-bold"
>
{icon && <span className="mr-2">{icon}</span>}
{text}
</Button>
{dismissible && (
<button
onClick={handleDismiss}
className="absolute -top-2 -right-2 w-6 h-6 bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-800 rounded-full flex items-center justify-center hover:bg-gray-700 dark:hover:bg-gray-300 transition-colors"
aria-label="Cerrar"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
);
};
export default FloatingCTA;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
interface LoaderProps {
size?: 'sm' | 'md' | 'lg' | 'default';
text?: string;
className?: string;
}
const Loader: React.FC<LoaderProps> = ({ size = 'default', text, className = '' }) => {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
default: 'h-10 w-10',
};
return (
<div className={`flex flex-col items-center justify-center ${className}`}>
<Loader2 className={`animate-spin text-primary ${sizeClasses[size]}`} />
{text && <span className="mt-2 text-sm text-muted-foreground">{text}</span>}
</div>
);
};
export { Loader };

View File

@@ -0,0 +1 @@
export { Loader } from './Loader';

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1 @@
export { Progress } from './Progress';

View File

@@ -0,0 +1,82 @@
import React, { useEffect, useState } from 'react';
import { motion, useScroll, useSpring } from 'framer-motion';
export interface ProgressBarProps {
/** Color of the progress bar */
color?: string;
/** Height of the progress bar in pixels */
height?: number;
/** Position of the progress bar */
position?: 'top' | 'bottom';
/** Show progress bar (default: true) */
show?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* ProgressBar - Shows page scroll progress
*
* Features:
* - Smooth animation with spring physics
* - Customizable color and height
* - Can be positioned at top or bottom
* - Automatically hides when at top of page
* - Zero-cost when not visible
*
* @example
* <ProgressBar color="var(--color-primary)" height={4} position="top" />
*/
export const ProgressBar: React.FC<ProgressBarProps> = ({
color = 'var(--color-primary)',
height = 4,
position = 'top',
show = true,
className = '',
}) => {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const unsubscribe = scrollYProgress.on('change', (latest) => {
// Show progress bar when scrolled past 100px
setIsVisible(latest > 0.05);
});
return () => unsubscribe();
}, [scrollYProgress]);
if (!show || !isVisible) {
return null;
}
return (
<motion.div
className={`fixed ${position === 'top' ? 'top-0' : 'bottom-0'} left-0 right-0 z-50 ${className}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
height: `${height}px`,
transformOrigin: '0%',
}}
>
<motion.div
style={{
scaleX,
height: '100%',
background: color,
transformOrigin: '0%',
}}
/>
</motion.div>
);
};
export default ProgressBar;

View File

@@ -0,0 +1,219 @@
import React, { useState } from 'react';
import { Calculator, TrendingUp } from 'lucide-react';
import { AnimatedCounter } from './AnimatedCounter';
import { Button } from './Button';
export interface SavingsCalculatorProps {
/** Default waste per day in units */
defaultWaste?: number;
/** Price per unit (e.g., €2 per loaf) */
pricePerUnit?: number;
/** Waste reduction percentage with AI (default: 80%) */
wasteReduction?: number;
/** Unit name (e.g., "barras", "loaves") */
unitName?: string;
/** Currency symbol */
currency?: string;
/** Additional CSS classes */
className?: string;
}
/**
* SavingsCalculator - Interactive calculator for waste reduction savings
*
* Features:
* - User inputs their current waste
* - Calculates potential savings with AI
* - Animated number counters
* - Daily, monthly, and yearly projections
* - Visual comparison (before/after)
*
* @example
* <SavingsCalculator
* defaultWaste={50}
* pricePerUnit={2}
* wasteReduction={80}
* unitName="barras"
* currency="€"
* />
*/
export const SavingsCalculator: React.FC<SavingsCalculatorProps> = ({
defaultWaste = 50,
pricePerUnit = 2,
wasteReduction = 80, // 80% reduction (from 50 to 10)
unitName = 'barras',
currency = '€',
className = '',
}) => {
const [wasteUnits, setWasteUnits] = useState<number>(defaultWaste);
const [showResults, setShowResults] = useState(false);
// Calculations
const currentDailyWaste = wasteUnits * pricePerUnit;
const currentMonthlyWaste = currentDailyWaste * 30;
const currentYearlyWaste = currentDailyWaste * 365;
const futureWasteUnits = Math.round(wasteUnits * (1 - wasteReduction / 100));
const futureDailyWaste = futureWasteUnits * pricePerUnit;
const futureMonthlyWaste = futureDailyWaste * 30;
const futureYearlyWaste = futureDailyWaste * 365;
const monthlySavings = currentMonthlyWaste - futureMonthlyWaste;
const yearlySavings = currentYearlyWaste - futureYearlyWaste;
const handleCalculate = () => {
setShowResults(true);
};
return (
<div className={`bg-gradient-to-br from-[var(--bg-primary)] to-[var(--bg-secondary)] rounded-2xl p-6 md:p-8 border-2 border-[var(--color-primary)] shadow-xl ${className}`}>
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-[var(--color-primary)] rounded-xl flex items-center justify-center">
<Calculator className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-xl font-bold text-[var(--text-primary)]">
Calculadora de Ahorros
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Descubre cuánto podrías ahorrar
</p>
</div>
</div>
{/* Input */}
<div className="mb-6">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
¿Cuántas {unitName} tiras al día en promedio?
</label>
<div className="flex gap-3">
<input
type="number"
value={wasteUnits}
onChange={(e) => setWasteUnits(Number(e.target.value))}
min="0"
max="1000"
className="flex-1 px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent"
placeholder="Ej: 50"
/>
<Button
onClick={handleCalculate}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white"
>
Calcular
</Button>
</div>
<p className="text-xs text-[var(--text-tertiary)] mt-2">
Precio por unidad: {currency}{pricePerUnit}
</p>
</div>
{/* Results */}
{showResults && wasteUnits > 0 && (
<div className="space-y-6 animate-in fade-in duration-500">
{/* Before/After Comparison */}
<div className="grid md:grid-cols-2 gap-4">
{/* Before */}
<div className="bg-red-50 dark:bg-red-900/20 rounded-xl p-4 border-2 border-red-200 dark:border-red-800">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-2">
Ahora (Sin IA)
</p>
<div className="text-2xl font-bold text-red-900 dark:text-red-100">
<AnimatedCounter
value={currentDailyWaste}
prefix={currency}
suffix="/día"
decimals={0}
duration={1.5}
/>
</div>
<p className="text-xs text-red-700 dark:text-red-400 mt-1">
{wasteUnits} {unitName} desperdiciadas
</p>
</div>
{/* After */}
<div className="bg-green-50 dark:bg-green-900/20 rounded-xl p-4 border-2 border-green-200 dark:border-green-800">
<p className="text-sm font-medium text-green-700 dark:text-green-400 mb-2">
Con Bakery-IA
</p>
<div className="text-2xl font-bold text-green-900 dark:text-green-100">
<AnimatedCounter
value={futureDailyWaste}
prefix={currency}
suffix="/día"
decimals={0}
duration={1.5}
delay={0.3}
/>
</div>
<p className="text-xs text-green-700 dark:text-green-400 mt-1">
{futureWasteUnits} {unitName} desperdiciadas
</p>
</div>
</div>
{/* Savings Highlight */}
<div className="bg-gradient-to-r from-[var(--color-primary)] to-orange-600 rounded-xl p-6 text-white">
<div className="flex items-center gap-3 mb-4">
<TrendingUp className="w-8 h-8" />
<h4 className="text-lg font-bold">Tu Ahorro Estimado</h4>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-white/80 text-sm mb-1">Al mes</p>
<p className="text-3xl font-bold">
<AnimatedCounter
value={monthlySavings}
prefix={currency}
decimals={0}
duration={2}
delay={0.5}
/>
</p>
</div>
<div>
<p className="text-white/80 text-sm mb-1">Al año</p>
<p className="text-3xl font-bold">
<AnimatedCounter
value={yearlySavings}
prefix={currency}
decimals={0}
duration={2}
delay={0.7}
/>
</p>
</div>
</div>
<p className="text-white/90 text-sm mt-4">
🎯 Reducción de desperdicios: {wasteReduction}% (de {wasteUnits} a {futureWasteUnits} {unitName}/día)
</p>
</div>
{/* ROI Message */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-4 border-l-4 border-[var(--color-primary)]">
<p className="text-sm font-medium text-[var(--text-primary)]">
💡 <strong>Recuperas la inversión en menos de 1 semana.</strong>
</p>
<p className="text-xs text-[var(--text-secondary)] mt-1">
Basado en predicciones 92% precisas y reducción de desperdicios de 20-40%.
</p>
</div>
</div>
)}
{showResults && wasteUnits === 0 && (
<div className="text-center py-8">
<p className="text-[var(--text-secondary)]">
Introduce una cantidad mayor que 0 para calcular tus ahorros
</p>
</div>
)}
</div>
);
};
export default SavingsCalculator;

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { motion, useInView } from 'framer-motion';
export interface ScrollRevealProps {
/** Children to animate */
children: React.ReactNode;
/** Animation variant */
variant?: 'fadeIn' | 'fadeUp' | 'fadeDown' | 'fadeLeft' | 'fadeRight' | 'scaleUp' | 'scaleDown';
/** Duration of animation in seconds */
duration?: number;
/** Delay before animation starts in seconds */
delay?: number;
/** Only animate once (default: true) */
once?: boolean;
/** Amount of element that must be visible to trigger (0-1) */
amount?: number;
/** Additional CSS classes */
className?: string;
/** Disable animation (renders children directly) */
disabled?: boolean;
}
const variants = {
fadeIn: {
hidden: { opacity: 0 },
visible: { opacity: 1 },
},
fadeUp: {
hidden: { opacity: 0, y: 40 },
visible: { opacity: 1, y: 0 },
},
fadeDown: {
hidden: { opacity: 0, y: -40 },
visible: { opacity: 1, y: 0 },
},
fadeLeft: {
hidden: { opacity: 0, x: 40 },
visible: { opacity: 1, x: 0 },
},
fadeRight: {
hidden: { opacity: 0, x: -40 },
visible: { opacity: 1, x: 0 },
},
scaleUp: {
hidden: { opacity: 0, scale: 0.8 },
visible: { opacity: 1, scale: 1 },
},
scaleDown: {
hidden: { opacity: 0, scale: 1.2 },
visible: { opacity: 1, scale: 1 },
},
};
/**
* ScrollReveal - Wrapper component that animates children when scrolling into view
*
* Features:
* - Multiple animation variants (fade, slide, scale)
* - Configurable duration and delay
* - Triggers only when element is in viewport
* - Respects prefers-reduced-motion
* - Optimized for performance
*
* @example
* <ScrollReveal variant="fadeUp" delay={0.2}>
* <h2>This will fade up when scrolled into view</h2>
* </ScrollReveal>
*/
export const ScrollReveal: React.FC<ScrollRevealProps> = ({
children,
variant = 'fadeUp',
duration = 0.6,
delay = 0,
once = true,
amount = 0.3,
className = '',
disabled = false,
}) => {
const ref = React.useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once, amount });
// Check for prefers-reduced-motion
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (disabled || prefersReducedMotion) {
return <div className={className}>{children}</div>;
}
const selectedVariants = variants[variant];
return (
<motion.div
ref={ref}
className={className}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
variants={selectedVariants}
transition={{
duration,
delay,
ease: [0.25, 0.1, 0.25, 1], // Custom easing for smoother animation
}}
>
{children}
</motion.div>
);
};
export default ScrollReveal;

View File

@@ -0,0 +1,189 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Check } from 'lucide-react';
export interface TimelineStep {
id: string;
number: number;
title: string;
description?: string;
items?: string[];
color: 'blue' | 'purple' | 'green' | 'amber' | 'red' | 'teal';
icon?: React.ReactNode;
}
export interface StepTimelineProps {
steps: TimelineStep[];
orientation?: 'vertical' | 'horizontal';
showConnector?: boolean;
animated?: boolean;
className?: string;
}
const colorClasses = {
blue: {
bg: 'from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20',
border: 'border-blue-200 dark:border-blue-800',
badge: 'bg-blue-600',
icon: 'text-blue-600',
line: 'bg-gradient-to-b from-blue-600 to-indigo-600',
},
purple: {
bg: 'from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20',
border: 'border-purple-200 dark:border-purple-800',
badge: 'bg-purple-600',
icon: 'text-purple-600',
line: 'bg-gradient-to-b from-purple-600 to-pink-600',
},
green: {
bg: 'from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20',
border: 'border-green-200 dark:border-green-800',
badge: 'bg-green-600',
icon: 'text-green-600',
line: 'bg-gradient-to-b from-green-600 to-emerald-600',
},
amber: {
bg: 'from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20',
border: 'border-amber-200 dark:border-amber-800',
badge: 'bg-amber-600',
icon: 'text-amber-600',
line: 'bg-gradient-to-b from-amber-600 to-orange-600',
},
red: {
bg: 'from-red-50 to-rose-50 dark:from-red-900/20 dark:to-rose-900/20',
border: 'border-red-200 dark:border-red-800',
badge: 'bg-red-600',
icon: 'text-red-600',
line: 'bg-gradient-to-b from-red-600 to-rose-600',
},
teal: {
bg: 'from-teal-50 to-cyan-50 dark:from-teal-900/20 dark:to-cyan-900/20',
border: 'border-teal-200 dark:border-teal-800',
badge: 'bg-teal-600',
icon: 'text-teal-600',
line: 'bg-gradient-to-b from-teal-600 to-cyan-600',
},
};
/**
* StepTimeline - Visual timeline for step-by-step processes
*
* Features:
* - Vertical or horizontal orientation
* - Connecting lines between steps
* - Color-coded steps
* - Optional animations
* - Support for icons and lists
*
* @example
* <StepTimeline
* steps={[
* { id: '1', number: 1, title: 'Step 1', color: 'blue', items: ['Item 1', 'Item 2'] },
* { id: '2', number: 2, title: 'Step 2', color: 'purple', items: ['Item 1', 'Item 2'] }
* ]}
* orientation="vertical"
* animated
* />
*/
export const StepTimeline: React.FC<StepTimelineProps> = ({
steps,
orientation = 'vertical',
showConnector = true,
animated = true,
className = '',
}) => {
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
},
},
};
const itemVariants = {
hidden: { opacity: 0, x: orientation === 'vertical' ? -20 : 0, y: orientation === 'horizontal' ? 20 : 0 },
visible: {
opacity: 1,
x: 0,
y: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.1, 0.25, 1],
},
},
};
return (
<motion.div
className={`${orientation === 'vertical' ? 'space-y-6' : 'flex gap-4 overflow-x-auto'} ${className}`}
variants={animated ? containerVariants : undefined}
initial={animated ? 'hidden' : undefined}
whileInView={animated ? 'visible' : undefined}
viewport={{ once: true, amount: 0.2 }}
>
{steps.map((step, index) => {
const colors = colorClasses[step.color];
const isLast = index === steps.length - 1;
return (
<motion.div
key={step.id}
className="relative"
variants={animated ? itemVariants : undefined}
>
{/* Connector Line */}
{showConnector && !isLast && orientation === 'vertical' && (
<div className="absolute left-8 top-20 bottom-0 w-1 -mb-6">
<div className={`h-full w-full ${colors.line} opacity-30`} />
</div>
)}
{/* Step Card */}
<div className={`bg-gradient-to-r ${colors.bg} rounded-2xl p-6 md:p-8 border-2 ${colors.border} relative z-10 hover:shadow-lg transition-shadow duration-300`}>
<div className="flex gap-4 md:gap-6 items-start">
{/* Number Badge */}
<div className={`w-16 h-16 ${colors.badge} rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0 shadow-lg`}>
{step.number}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h3 className="text-xl md:text-2xl font-bold text-[var(--text-primary)] mb-3">
{step.title}
</h3>
{step.description && (
<p className="text-[var(--text-secondary)] mb-4">
{step.description}
</p>
)}
{step.items && step.items.length > 0 && (
<ul className="space-y-2">
{step.items.map((item, itemIndex) => (
<li key={itemIndex} className="flex items-start gap-2">
<Check className={`w-5 h-5 ${colors.icon} mt-0.5 flex-shrink-0`} />
<span className="text-[var(--text-secondary)]">{item}</span>
</li>
))}
</ul>
)}
{step.icon && (
<div className="mt-4">
{step.icon}
</div>
)}
</div>
</div>
</div>
</motion.div>
);
})}
</motion.div>
);
};
export default StepTimeline;

View File

@@ -0,0 +1,180 @@
import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Menu, X } from 'lucide-react';
export interface TOCSection {
id: string;
label: string;
icon?: React.ReactNode;
}
export interface TableOfContentsProps {
/** Array of sections to display */
sections: TOCSection[];
/** Additional CSS classes */
className?: string;
/** Show on mobile (default: false) */
showOnMobile?: boolean;
/** Offset for scroll position (for fixed headers) */
scrollOffset?: number;
}
/**
* TableOfContents - Sticky navigation for page sections
*
* Features:
* - Highlights current section based on scroll position
* - Smooth scroll to sections
* - Collapsible on mobile
* - Responsive design
* - Keyboard accessible
*
* @example
* <TableOfContents
* sections={[
* { id: 'automatic-system', label: 'Sistema Automático' },
* { id: 'local-intelligence', label: 'Inteligencia Local' },
* ]}
* />
*/
export const TableOfContents: React.FC<TableOfContentsProps> = ({
sections,
className = '',
showOnMobile = false,
scrollOffset = 100,
}) => {
const [activeSection, setActiveSection] = useState<string>('');
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleScroll = () => {
const scrollPosition = window.scrollY + scrollOffset;
// Find the current section
for (let i = sections.length - 1; i >= 0; i--) {
const section = document.getElementById(sections[i].id);
if (section && section.offsetTop <= scrollPosition) {
setActiveSection(sections[i].id);
break;
}
}
};
window.addEventListener('scroll', handleScroll);
handleScroll(); // Check initial position
return () => window.removeEventListener('scroll', handleScroll);
}, [sections, scrollOffset]);
const scrollToSection = (sectionId: string) => {
const element = document.getElementById(sectionId);
if (element) {
const top = element.offsetTop - scrollOffset + 20;
window.scrollTo({ top, behavior: 'smooth' });
setIsOpen(false); // Close mobile menu after click
}
};
return (
<>
{/* Mobile Toggle Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`fixed top-20 right-4 z-50 lg:hidden ${showOnMobile ? '' : 'hidden'} bg-[var(--bg-primary)] border-2 border-[var(--border-primary)] rounded-lg p-2 shadow-lg`}
aria-label="Toggle table of contents"
>
{isOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
{/* Desktop Sidebar */}
<nav
className={`hidden lg:block sticky top-24 h-fit max-h-[calc(100vh-120px)] overflow-y-auto ${className}`}
aria-label="Table of contents"
>
<div className="bg-[var(--bg-secondary)] rounded-2xl p-6 border border-[var(--border-primary)]">
<h2 className="text-sm font-bold text-[var(--text-secondary)] uppercase tracking-wider mb-4">
Contenido
</h2>
<ul className="space-y-2">
{sections.map((section) => (
<li key={section.id}>
<button
onClick={() => scrollToSection(section.id)}
className={`w-full text-left px-3 py-2 rounded-lg transition-all duration-200 flex items-center gap-2 ${
activeSection === section.id
? 'bg-[var(--color-primary)] text-white font-medium'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] hover:text-[var(--text-primary)]'
}`}
>
{section.icon && <span className="flex-shrink-0">{section.icon}</span>}
<span className="text-sm">{section.label}</span>
</button>
</li>
))}
</ul>
</div>
</nav>
{/* Mobile Drawer */}
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsOpen(false)}
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
/>
{/* Drawer */}
<motion.nav
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="fixed top-0 right-0 bottom-0 w-80 max-w-[80vw] bg-[var(--bg-primary)] shadow-2xl z-50 lg:hidden overflow-y-auto"
aria-label="Table of contents"
>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-bold text-[var(--text-primary)]">
Contenido
</h2>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-[var(--bg-secondary)] rounded-lg transition-colors"
aria-label="Cerrar"
>
<X className="w-6 h-6" />
</button>
</div>
<ul className="space-y-2">
{sections.map((section) => (
<li key={section.id}>
<button
onClick={() => scrollToSection(section.id)}
className={`w-full text-left px-4 py-3 rounded-lg transition-all duration-200 flex items-center gap-3 ${
activeSection === section.id
? 'bg-[var(--color-primary)] text-white font-medium'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] hover:text-[var(--text-primary)]'
}`}
>
{section.icon && <span className="flex-shrink-0">{section.icon}</span>}
<span>{section.label}</span>
</button>
</li>
))}
</ul>
</div>
</motion.nav>
</>
)}
</AnimatePresence>
</>
);
};
export default TableOfContents;

View File

@@ -29,6 +29,17 @@ export { EmptyState } from './EmptyState';
export { ResponsiveText } from './ResponsiveText';
export { SearchAndFilter } from './SearchAndFilter';
export { BaseDeleteModal } from './BaseDeleteModal';
export { Alert, AlertTitle, AlertDescription } from './Alert';
export { Progress } from './Progress';
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './Accordion';
export { Loader } from './Loader';
export { AnimatedCounter } from './AnimatedCounter';
export { ScrollReveal } from './ScrollReveal';
export { FloatingCTA } from './FloatingCTA';
export { TableOfContents } from './TableOfContents';
export { SavingsCalculator } from './SavingsCalculator';
export { StepTimeline } from './StepTimeline';
export { FAQAccordion } from './FAQAccordion';
// Export types
export type { ButtonProps } from './Button';
@@ -58,4 +69,12 @@ export type { LoadingSpinnerProps } from './LoadingSpinner';
export type { EmptyStateProps } from './EmptyState';
export type { ResponsiveTextProps } from './ResponsiveText';
export type { SearchAndFilterProps, FilterConfig, FilterOption } from './SearchAndFilter';
export type { BaseDeleteModalProps, DeleteMode, EntityDisplayInfo, DeleteModeOption, DeleteWarning, DeletionSummaryData } from './BaseDeleteModal';
export type { BaseDeleteModalProps, DeleteMode, EntityDisplayInfo, DeleteModeOption, DeleteWarning, DeletionSummaryData } from './BaseDeleteModal';
export type { LoaderProps } from './Loader';
export type { AnimatedCounterProps } from './AnimatedCounter';
export type { ScrollRevealProps } from './ScrollReveal';
export type { FloatingCTAProps } from './FloatingCTA';
export type { TableOfContentsProps, TOCSection } from './TableOfContents';
export type { SavingsCalculatorProps } from './SavingsCalculator';
export type { StepTimelineProps, TimelineStep } from './StepTimeline';
export type { FAQAccordionProps, FAQItem } from './FAQAccordion';