Add POI feature and imporve the overall backend implementation
This commit is contained in:
56
frontend/src/components/ui/Accordion.tsx
Normal file
56
frontend/src/components/ui/Accordion.tsx
Normal 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 };
|
||||
1
frontend/src/components/ui/Accordion/index.ts
Normal file
1
frontend/src/components/ui/Accordion/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './Accordion';
|
||||
191
frontend/src/components/ui/AddressAutocomplete.tsx
Normal file
191
frontend/src/components/ui/AddressAutocomplete.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
60
frontend/src/components/ui/Alert.tsx
Normal file
60
frontend/src/components/ui/Alert.tsx
Normal 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 };
|
||||
1
frontend/src/components/ui/Alert/index.ts
Normal file
1
frontend/src/components/ui/Alert/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Alert, AlertTitle, AlertDescription } from './Alert';
|
||||
101
frontend/src/components/ui/AnimatedCounter.tsx
Normal file
101
frontend/src/components/ui/AnimatedCounter.tsx
Normal 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;
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
154
frontend/src/components/ui/FAQAccordion.tsx
Normal file
154
frontend/src/components/ui/FAQAccordion.tsx
Normal 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;
|
||||
132
frontend/src/components/ui/FloatingCTA.tsx
Normal file
132
frontend/src/components/ui/FloatingCTA.tsx
Normal 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;
|
||||
26
frontend/src/components/ui/Loader.tsx
Normal file
26
frontend/src/components/ui/Loader.tsx
Normal 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 };
|
||||
1
frontend/src/components/ui/Loader/index.ts
Normal file
1
frontend/src/components/ui/Loader/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Loader } from './Loader';
|
||||
26
frontend/src/components/ui/Progress.tsx
Normal file
26
frontend/src/components/ui/Progress.tsx
Normal 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 };
|
||||
1
frontend/src/components/ui/Progress/index.ts
Normal file
1
frontend/src/components/ui/Progress/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Progress } from './Progress';
|
||||
82
frontend/src/components/ui/ProgressBar.tsx
Normal file
82
frontend/src/components/ui/ProgressBar.tsx
Normal 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;
|
||||
219
frontend/src/components/ui/SavingsCalculator.tsx
Normal file
219
frontend/src/components/ui/SavingsCalculator.tsx
Normal 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;
|
||||
111
frontend/src/components/ui/ScrollReveal.tsx
Normal file
111
frontend/src/components/ui/ScrollReveal.tsx
Normal 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;
|
||||
189
frontend/src/components/ui/StepTimeline.tsx
Normal file
189
frontend/src/components/ui/StepTimeline.tsx
Normal 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;
|
||||
180
frontend/src/components/ui/TableOfContents.tsx
Normal file
180
frontend/src/components/ui/TableOfContents.tsx
Normal 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;
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user