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

@@ -87,6 +87,7 @@ class ApiClient {
'/auth/me', // User profile endpoints
'/auth/register', // Registration
'/auth/login', // Login
'/geocoding', // Geocoding/address search - utility service, no tenant context
];
const isPublicEndpoint = publicEndpoints.some(endpoint =>

View File

@@ -169,11 +169,11 @@ export const useCreateOrder = (
queryKey: ordersKeys.orders(),
predicate: (query) => {
const queryKey = query.queryKey as string[];
return queryKey.includes('list') &&
return queryKey.includes('list') &&
JSON.stringify(queryKey).includes(variables.tenant_id);
},
});
// Invalidate dashboard
queryClient.invalidateQueries({
queryKey: ordersKeys.dashboard(variables.tenant_id),
@@ -189,6 +189,39 @@ export const useCreateOrder = (
});
};
export const useUpdateOrder = (
options?: UseMutationOptions<OrderResponse, ApiError, { tenantId: string; orderId: string; data: OrderUpdate }>
) => {
const queryClient = useQueryClient();
return useMutation<OrderResponse, ApiError, { tenantId: string; orderId: string; data: OrderUpdate }>({
mutationFn: ({ tenantId, orderId, data }) => OrdersService.updateOrder(tenantId, orderId, data),
onSuccess: (data, variables) => {
// Update the specific order in cache
queryClient.setQueryData(
ordersKeys.order(variables.tenantId, variables.orderId),
data
);
// Invalidate orders list for this tenant
queryClient.invalidateQueries({
queryKey: ordersKeys.orders(),
predicate: (query) => {
const queryKey = query.queryKey as string[];
return queryKey.includes('list') &&
JSON.stringify(queryKey).includes(variables.tenantId);
},
});
// Invalidate dashboard
queryClient.invalidateQueries({
queryKey: ordersKeys.dashboard(variables.tenantId),
});
},
...options,
});
};
export const useUpdateOrderStatus = (
options?: UseMutationOptions<OrderResponse, ApiError, UpdateOrderStatusParams>
) => {

View File

@@ -10,7 +10,9 @@ import type {
PurchaseOrderDetail,
PurchaseOrderSearchParams,
PurchaseOrderUpdateData,
PurchaseOrderStatus
PurchaseOrderStatus,
CreateDeliveryInput,
DeliveryResponse
} from '../services/purchase_orders';
import {
listPurchaseOrders,
@@ -21,7 +23,8 @@ import {
approvePurchaseOrder,
rejectPurchaseOrder,
bulkApprovePurchaseOrders,
deletePurchaseOrder
deletePurchaseOrder,
createDelivery
} from '../services/purchase_orders';
// Query Keys
@@ -257,3 +260,33 @@ export const useDeletePurchaseOrder = (
...options,
});
};
/**
* Hook to create a delivery for a purchase order
*/
export const useCreateDelivery = (
options?: UseMutationOptions<
DeliveryResponse,
ApiError,
{ tenantId: string; poId: string; deliveryData: CreateDeliveryInput }
>
) => {
const queryClient = useQueryClient();
return useMutation<
DeliveryResponse,
ApiError,
{ tenantId: string; poId: string; deliveryData: CreateDeliveryInput }
>({
mutationFn: ({ tenantId, poId, deliveryData }) => createDelivery(tenantId, poId, deliveryData),
onSuccess: (data, variables) => {
// Invalidate all PO queries to refresh status
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.all });
// Invalidate detail for this specific PO
queryClient.invalidateQueries({
queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId)
});
},
...options,
});
};

View File

@@ -629,6 +629,7 @@ export {
useBusinessModelDetection,
useOrdersServiceStatus,
useCreateOrder,
useUpdateOrder,
useUpdateOrderStatus,
useCreateCustomer,
useUpdateCustomer,

View File

@@ -10,11 +10,12 @@ export const BACKEND_ONBOARDING_STEPS = [
'user_registered', // Phase 0: User account created (auto-completed)
'bakery-type-selection', // Phase 1: Choose bakery type
'setup', // Phase 2: Basic bakery setup and tenant creation
'upload-sales-data', // Phase 2a: File upload, validation, AI classification
'inventory-review', // Phase 2a: Review AI-detected products with type selection
'initial-stock-entry', // Phase 2a: Capture initial stock levels
'product-categorization', // Phase 2b: Advanced categorization (optional)
'suppliers-setup', // Phase 2c: Suppliers configuration
'poi-detection', // Phase 2a: POI Detection (Location Context)
'upload-sales-data', // Phase 2b: File upload, validation, AI classification
'inventory-review', // Phase 2b: Review AI-detected products with type selection
'initial-stock-entry', // Phase 2b: Capture initial stock levels
'product-categorization', // Phase 2c: Advanced categorization (optional)
'suppliers-setup', // Phase 2d: Suppliers configuration
'recipes-setup', // Phase 3: Production recipes (optional)
'production-processes', // Phase 3: Finishing processes (optional)
'quality-setup', // Phase 3: Quality standards (optional)
@@ -28,11 +29,12 @@ export const BACKEND_ONBOARDING_STEPS = [
export const FRONTEND_STEP_ORDER = [
'bakery-type-selection', // Phase 1: Choose bakery type
'setup', // Phase 2: Basic bakery setup and tenant creation
'upload-sales-data', // Phase 2a: File upload and AI classification
'inventory-review', // Phase 2a: Review AI-detected products
'initial-stock-entry', // Phase 2a: Initial stock levels
'product-categorization', // Phase 2b: Advanced categorization (optional)
'suppliers-setup', // Phase 2c: Suppliers configuration
'poi-detection', // Phase 2a: POI Detection (Location Context)
'upload-sales-data', // Phase 2b: File upload and AI classification
'inventory-review', // Phase 2b: Review AI-detected products
'initial-stock-entry', // Phase 2b: Initial stock levels
'product-categorization', // Phase 2c: Advanced categorization (optional)
'suppliers-setup', // Phase 2d: Suppliers configuration
'recipes-setup', // Phase 3: Production recipes (optional)
'production-processes', // Phase 3: Finishing processes (optional)
'quality-setup', // Phase 3: Quality standards (optional)

View File

@@ -103,20 +103,28 @@ export class OrdersService {
return apiClient.get<OrderResponse[]>(`/tenants/${tenant_id}/orders?${queryParams.toString()}`);
}
/**
* Update order details
* PUT /tenants/{tenant_id}/orders/{order_id}
*/
static async updateOrder(tenantId: string, orderId: string, orderData: OrderUpdate): Promise<OrderResponse> {
return apiClient.put<OrderResponse>(`/tenants/${tenantId}/orders/${orderId}`, orderData);
}
/**
* Update order status
* PUT /tenants/{tenant_id}/orders/{order_id}/status
*/
static async updateOrderStatus(params: UpdateOrderStatusParams): Promise<OrderResponse> {
const { tenant_id, order_id, new_status, reason } = params;
const queryParams = new URLSearchParams();
if (reason) {
queryParams.append('reason', reason);
}
const url = `/tenants/${tenant_id}/orders/${order_id}/status${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return apiClient.put<OrderResponse>(url, { status: new_status });
}

View File

@@ -242,3 +242,68 @@ export async function deletePurchaseOrder(
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`
);
}
// ================================================================
// DELIVERY TYPES AND METHODS
// ================================================================
export interface DeliveryItemInput {
purchase_order_item_id: string;
inventory_product_id: string;
ordered_quantity: number;
delivered_quantity: number;
accepted_quantity: number;
rejected_quantity: number;
batch_lot_number?: string;
expiry_date?: string;
quality_grade?: string;
quality_issues?: string;
rejection_reason?: string;
item_notes?: string;
}
export interface CreateDeliveryInput {
purchase_order_id: string;
supplier_id: string;
supplier_delivery_note?: string;
scheduled_date?: string;
estimated_arrival?: string;
carrier_name?: string;
tracking_number?: string;
inspection_passed?: boolean;
inspection_notes?: string;
notes?: string;
items: DeliveryItemInput[];
}
export interface DeliveryResponse {
id: string;
tenant_id: string;
purchase_order_id: string;
supplier_id: string;
delivery_number: string;
status: string;
scheduled_date?: string;
estimated_arrival?: string;
actual_arrival?: string;
completed_at?: string;
inspection_passed?: boolean;
inspection_notes?: string;
notes?: string;
created_at: string;
updated_at: string;
}
/**
* Create delivery for purchase order
*/
export async function createDelivery(
tenantId: string,
poId: string,
deliveryData: CreateDeliveryInput
): Promise<DeliveryResponse> {
return apiClient.post<DeliveryResponse>(
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/deliveries`,
deliveryData
);
}

View File

@@ -120,6 +120,13 @@ export interface ProductionBatchResponse {
actual_duration_minutes: number | null;
status: ProductionStatus;
priority: ProductionPriority;
// Process stage tracking (replaces frontend mock data)
current_process_stage?: string | null;
process_stage_history?: Array<Record<string, any>> | null;
pending_quality_checks?: Array<Record<string, any>> | null;
completed_quality_checks?: Array<Record<string, any>> | null;
estimated_cost: number | null;
actual_cost: number | null;
yield_percentage: number | null;

View File

@@ -19,17 +19,25 @@ import {
Euro,
ChevronDown,
ChevronUp,
X,
Package,
Building2,
Calendar,
Truck,
} from 'lucide-react';
import { ActionItem, ActionQueue } from '../../api/hooks/newDashboard';
import { useReasoningFormatter } from '../../hooks/useReasoningTranslation';
import { useTranslation } from 'react-i18next';
import { usePurchaseOrder } from '../../api/hooks/purchase-orders';
interface ActionQueueCardProps {
actionQueue: ActionQueue;
loading?: boolean;
onApprove?: (actionId: string) => void;
onReject?: (actionId: string, reason: string) => void;
onViewDetails?: (actionId: string) => void;
onModify?: (actionId: string) => void;
tenantId?: string;
}
const urgencyConfig = {
@@ -62,20 +70,34 @@ const urgencyConfig = {
function ActionItemCard({
action,
onApprove,
onReject,
onViewDetails,
onModify,
tenantId,
}: {
action: ActionItem;
onApprove?: (id: string) => void;
onReject?: (id: string, reason: string) => void;
onViewDetails?: (id: string) => void;
onModify?: (id: string) => void;
tenantId?: string;
}) {
const [expanded, setExpanded] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
const [rejectionReason, setRejectionReason] = useState('');
const config = urgencyConfig[action.urgency as keyof typeof urgencyConfig] || urgencyConfig.normal;
const UrgencyIcon = config.icon;
const { formatPOAction } = useReasoningFormatter();
const { t } = useTranslation('reasoning');
// Fetch PO details if this is a PO action and details are expanded
const { data: poDetail } = usePurchaseOrder(
tenantId || '',
action.id,
{ enabled: !!tenantId && showDetails && action.type === 'po_approval' }
);
// Translate reasoning_data (or fallback to deprecated text fields)
// Memoize to prevent undefined values from being created on each render
const { reasoning, consequence, severity } = useMemo(() => {
@@ -166,6 +188,157 @@ function ActionItemCard({
</>
)}
{/* Inline PO Details (expandable) */}
{action.type === 'po_approval' && (
<>
<button
onClick={() => setShowDetails(!showDetails)}
className="flex items-center gap-2 text-sm font-medium transition-colors mb-3 w-full"
style={{ color: 'var(--color-info-700)' }}
>
{showDetails ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
<Package className="w-4 h-4" />
<span>View Order Details</span>
</button>
{showDetails && poDetail && (
<div
className="border rounded-md p-4 mb-3 space-y-3"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
}}
>
{/* Supplier Info */}
<div className="flex items-start gap-2">
<Building2 className="w-5 h-5 flex-shrink-0" style={{ color: 'var(--color-info-600)' }} />
<div className="flex-1">
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{poDetail.supplier?.name || 'Supplier'}
</p>
{poDetail.supplier?.contact_person && (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Contact: {poDetail.supplier.contact_person}
</p>
)}
{poDetail.supplier?.email && (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{poDetail.supplier.email}
</p>
)}
</div>
</div>
{/* Delivery Date & Tracking */}
{poDetail.required_delivery_date && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5" style={{ color: 'var(--color-warning-600)' }} />
<div className="flex-1">
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Required Delivery
</p>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{new Date(poDetail.required_delivery_date).toLocaleDateString()}
</p>
</div>
</div>
{/* Estimated Delivery Date (shown after approval) */}
{poDetail.estimated_delivery_date && (
<div className="flex items-center gap-2 ml-7">
<Truck className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
<div className="flex-1">
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Expected Arrival
</p>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{new Date(poDetail.estimated_delivery_date).toLocaleDateString()}
</p>
</div>
{(() => {
const now = new Date();
const estimatedDate = new Date(poDetail.estimated_delivery_date);
const daysUntil = Math.ceil((estimatedDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
let statusColor = 'var(--color-success-600)';
let statusText = 'On Track';
if (daysUntil < 0) {
statusColor = 'var(--color-error-600)';
statusText = `${Math.abs(daysUntil)}d Overdue`;
} else if (daysUntil === 0) {
statusColor = 'var(--color-warning-600)';
statusText = 'Due Today';
} else if (daysUntil <= 2) {
statusColor = 'var(--color-warning-600)';
statusText = `${daysUntil}d Left`;
} else {
statusText = `${daysUntil}d Left`;
}
return (
<span
className="px-2 py-1 rounded text-xs font-semibold"
style={{
backgroundColor: statusColor.replace('600', '100'),
color: statusColor,
}}
>
{statusText}
</span>
);
})()}
</div>
)}
</div>
)}
{/* Line Items */}
{poDetail.items && poDetail.items.length > 0 && (
<div>
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>
Order Items ({poDetail.items.length})
</p>
<div className="space-y-2 max-h-48 overflow-y-auto">
{poDetail.items.map((item, idx) => (
<div
key={idx}
className="flex justify-between items-start p-2 rounded"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
<div className="flex-1">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{item.product_name || item.product_code || 'Product'}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{item.ordered_quantity} {item.unit_of_measure} × {parseFloat(item.unit_price).toFixed(2)}
</p>
</div>
<p className="text-sm font-bold" style={{ color: 'var(--text-primary)' }}>
{parseFloat(item.line_total).toFixed(2)}
</p>
</div>
))}
</div>
</div>
)}
{/* Total Amount */}
<div
className="border-t pt-2 flex justify-between items-center"
style={{ borderColor: 'var(--border-primary)' }}
>
<p className="text-sm font-bold" style={{ color: 'var(--text-primary)' }}>Total Amount</p>
<p className="text-lg font-bold" style={{ color: 'var(--color-info-700)' }}>
{parseFloat(poDetail.total_amount).toFixed(2)}
</p>
</div>
</div>
)}
</>
)}
{/* Time Estimate */}
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: 'var(--text-tertiary)' }}>
<Clock className="w-4 h-4" />
@@ -174,6 +347,79 @@ function ActionItemCard({
</span>
</div>
{/* Rejection Modal */}
{showRejectModal && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
onClick={() => setShowRejectModal(false)}
>
<div
className="rounded-lg p-6 max-w-md w-full"
style={{ backgroundColor: 'var(--bg-primary)' }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>
Reject Purchase Order
</h3>
<button
onClick={() => setShowRejectModal(false)}
className="p-1 rounded hover:bg-opacity-10 hover:bg-black"
>
<X className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
Please provide a reason for rejecting this purchase order:
</p>
<textarea
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
placeholder="Enter rejection reason..."
className="w-full p-3 border rounded-lg mb-4 min-h-24"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowRejectModal(false)}
className="px-4 py-2 rounded-lg font-semibold transition-colors"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
}}
>
Cancel
</button>
<button
onClick={() => {
if (onReject && rejectionReason.trim()) {
onReject(action.id, rejectionReason);
setShowRejectModal(false);
setRejectionReason('');
}
}}
disabled={!rejectionReason.trim()}
className="px-4 py-2 rounded-lg font-semibold transition-colors"
style={{
backgroundColor: rejectionReason.trim() ? 'var(--color-error-600)' : 'var(--bg-quaternary)',
color: rejectionReason.trim() ? 'white' : 'var(--text-tertiary)',
cursor: rejectionReason.trim() ? 'pointer' : 'not-allowed',
}}
>
Reject Order
</button>
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
{(action.actions || []).map((button, index) => {
@@ -210,6 +456,8 @@ function ActionItemCard({
const handleClick = () => {
if (button.action === 'approve' && onApprove) {
onApprove(action.id);
} else if (button.action === 'reject') {
setShowRejectModal(true);
} else if (button.action === 'view_details' && onViewDetails) {
onViewDetails(action.id);
} else if (button.action === 'modify' && onModify) {
@@ -232,6 +480,7 @@ function ActionItemCard({
style={currentStyle}
>
{button.action === 'approve' && <CheckCircle2 className="w-4 h-4" />}
{button.action === 'reject' && <X className="w-4 h-4" />}
{button.action === 'view_details' && <Eye className="w-4 h-4" />}
{button.action === 'modify' && <Edit className="w-4 h-4" />}
{button.label}
@@ -247,8 +496,10 @@ export function ActionQueueCard({
actionQueue,
loading,
onApprove,
onReject,
onViewDetails,
onModify,
tenantId,
}: ActionQueueCardProps) {
const [showAll, setShowAll] = useState(false);
const { t } = useTranslation('reasoning');
@@ -338,8 +589,10 @@ export function ActionQueueCard({
key={action.id}
action={action}
onApprove={onApprove}
onReject={onReject}
onViewDetails={onViewDetails}
onModify={onModify}
tenantId={tenantId}
/>
))}
</div>

View File

@@ -11,6 +11,7 @@ import { WizardProvider, useWizardContext, BakeryType, DataSource } from './cont
import {
BakeryTypeSelectionStep,
RegisterTenantStep,
POIDetectionStep,
FileUploadStep,
InventoryReviewStep,
ProductCategorizationStep,
@@ -74,6 +75,15 @@ const OnboardingWizardContent: React.FC = () => {
isConditional: true,
condition: (ctx) => ctx.state.bakeryType !== null,
},
// Phase 2b: POI Detection
{
id: 'poi-detection',
title: t('onboarding:steps.poi_detection.title', 'Detección de Ubicación'),
description: t('onboarding:steps.poi_detection.description', 'Analizar puntos de interés cercanos'),
component: POIDetectionStep,
isConditional: true,
condition: (ctx) => ctx.state.bakeryType !== null && ctx.state.bakeryLocation !== undefined,
},
// Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps)
{
id: 'upload-sales-data',
@@ -325,6 +335,10 @@ const OnboardingWizardContent: React.FC = () => {
}
if (currentStep.id === 'inventory-review') {
wizardContext.markStepComplete('inventoryReviewCompleted');
// Store inventory items in context for the next step
if (data?.inventoryItems) {
wizardContext.updateInventoryItems(data.inventoryItems);
}
}
if (currentStep.id === 'product-categorization' && data?.categorizedProducts) {
wizardContext.updateCategorizedProducts(data.categorizedProducts);
@@ -339,6 +353,11 @@ const OnboardingWizardContent: React.FC = () => {
}
if (currentStep.id === 'setup' && data?.tenant) {
setCurrentTenant(data.tenant);
// If tenant info and location are available in data, update the wizard context
if (data.tenantId && data.bakeryLocation) {
wizardContext.updateTenantInfo(data.tenantId, data.bakeryLocation);
}
}
// Mark step as completed in backend
@@ -531,6 +550,24 @@ const OnboardingWizardContent: React.FC = () => {
uploadedFileName: wizardContext.state.uploadedFileName || '',
uploadedFileSize: wizardContext.state.uploadedFileSize || 0,
}
: // Pass inventory items to InitialStockEntryStep
currentStep.id === 'initial-stock-entry' && wizardContext.state.inventoryItems
? {
productsWithStock: wizardContext.state.inventoryItems.map(item => ({
id: item.id,
name: item.name,
type: item.product_type === 'ingredient' ? 'ingredient' : 'finished_product',
category: item.category,
unit: item.unit_of_measure,
initialStock: undefined,
}))
}
: // Pass tenant info to POI detection step
currentStep.id === 'poi-detection'
? {
tenantId: wizardContext.state.tenantId,
bakeryLocation: wizardContext.state.bakeryLocation,
}
: undefined
}
/>

View File

@@ -16,11 +16,34 @@ export interface AISuggestion {
isAccepted?: boolean;
}
// Inventory item structure from InventoryReviewStep
export interface InventoryItemForm {
id: string;
name: string;
product_type: string;
category: string;
unit_of_measure: string;
isSuggested?: boolean;
confidence_score?: number;
sales_data?: {
total_quantity: number;
total_revenue: number;
average_price: number;
};
}
export interface WizardState {
// Discovery Phase
bakeryType: BakeryType;
dataSource: DataSource;
// Core Setup Data
tenantId?: string;
bakeryLocation?: {
latitude: number;
longitude: number;
};
// AI-Assisted Path Data
uploadedFile?: File; // NEW: The actual file object needed for sales import API
uploadedFileName?: string;
@@ -30,6 +53,7 @@ export interface WizardState {
aiAnalysisComplete: boolean;
categorizedProducts?: any[]; // Products with type classification
productsWithStock?: any[]; // Products with initial stock levels
inventoryItems?: InventoryItemForm[]; // NEW: Inventory items created in InventoryReviewStep
// Setup Progress
categorizationCompleted: boolean;
@@ -55,11 +79,15 @@ export interface WizardContextValue {
state: WizardState;
updateBakeryType: (type: BakeryType) => void;
updateDataSource: (source: DataSource) => void;
updateTenantInfo: (tenantId: string, location: { latitude: number; longitude: number }) => void;
updateLocation: (location: { latitude: number; longitude: number }) => void;
updateTenantId: (tenantId: string) => void;
updateAISuggestions: (suggestions: ProductSuggestionResponse[]) => void; // UPDATED type
updateUploadedFile: (file: File, validation: ImportValidationResponse) => void; // UPDATED: store file object and validation
setAIAnalysisComplete: (complete: boolean) => void;
updateCategorizedProducts: (products: any[]) => void;
updateProductsWithStock: (products: any[]) => void;
updateInventoryItems: (items: InventoryItemForm[]) => void; // NEW: Store inventory items
markStepComplete: (step: keyof WizardState) => void;
getVisibleSteps: () => string[];
shouldShowStep: (stepId: string) => boolean;
@@ -126,6 +154,28 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
setState(prev => ({ ...prev, dataSource: source }));
};
const updateTenantInfo = (tenantId: string, location: { latitude: number; longitude: number }) => {
setState(prev => ({
...prev,
tenantId,
bakeryLocation: location
}));
};
const updateLocation = (location: { latitude: number; longitude: number }) => {
setState(prev => ({
...prev,
bakeryLocation: location
}));
};
const updateTenantId = (tenantId: string) => {
setState(prev => ({
...prev,
tenantId
}));
};
const updateAISuggestions = (suggestions: ProductSuggestionResponse[]) => {
setState(prev => ({ ...prev, aiSuggestions: suggestions }));
};
@@ -152,6 +202,10 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
setState(prev => ({ ...prev, productsWithStock: products }));
};
const updateInventoryItems = (items: InventoryItemForm[]) => {
setState(prev => ({ ...prev, inventoryItems: items }));
};
const markStepComplete = (step: keyof WizardState) => {
setState(prev => ({ ...prev, [step]: true }));
};
@@ -244,11 +298,15 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
state,
updateBakeryType,
updateDataSource,
updateTenantInfo,
updateLocation,
updateTenantId,
updateAISuggestions,
updateUploadedFile,
setAIAnalysisComplete,
updateCategorizedProducts,
updateProductsWithStock,
updateInventoryItems,
markStepComplete,
getVisibleSteps,
shouldShowStep,

View File

@@ -330,10 +330,11 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
}
}
// Complete the step with metadata
// Complete the step with metadata and inventory items
onComplete({
inventoryItemsCreated: inventoryItems.length,
salesDataImported: salesImported,
inventoryItems: inventoryItems, // Pass the created items to the next step
});
} catch (error) {
console.error('Error creating inventory items:', error);

View File

@@ -0,0 +1,346 @@
/**
* POI Detection Onboarding Step
*
* Onboarding wizard step for automatic POI detection during bakery registration
*/
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/Card';
import { Alert, AlertDescription } from '@/components/ui/Alert';
import { Progress } from '@/components/ui/Progress';
import { CheckCircle, MapPin, AlertCircle, Loader2, ArrowRight } from 'lucide-react';
import { poiContextApi } from '@/services/api/poiContextApi';
import { POI_CATEGORY_METADATA } from '@/types/poi';
import type { POIDetectionResponse } from '@/types/poi';
import { useWizardContext } from '../context';
interface POIDetectionStepProps {
onNext?: () => void;
onPrevious?: () => void;
onComplete?: (data?: any) => void;
onUpdate?: (data: any) => void;
isFirstStep?: boolean;
isLastStep?: boolean;
initialData?: any;
}
export const POIDetectionStep: React.FC<POIDetectionStepProps> = ({
onComplete,
onUpdate,
initialData,
}) => {
const [isDetecting, setIsDetecting] = useState(false);
const [detectionResult, setDetectionResult] = useState<POIDetectionResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const wizardContext = useWizardContext();
// Extract tenantId and location from context or initialData
// Prioritize initialData (from previous step completion), fall back to context
const tenantId = initialData?.tenantId || wizardContext.state.tenantId;
const bakeryLocation = initialData?.bakeryLocation || wizardContext.state.bakeryLocation;
// Auto-detect POIs when both tenantId and location are available
useEffect(() => {
if (tenantId && bakeryLocation?.latitude && bakeryLocation?.longitude) {
handleDetectPOIs();
} else {
// If we don't have the required data, show a message
setError('Location data not available. Please complete the previous step first.');
}
}, [tenantId, bakeryLocation]);
const handleDetectPOIs = async () => {
if (!tenantId || !bakeryLocation?.latitude || !bakeryLocation?.longitude) {
setError('Tenant ID and location are required for POI detection.');
return;
}
setIsDetecting(true);
setError(null);
setProgress(10);
try {
// Simulate progress updates
const progressInterval = setInterval(() => {
setProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval);
return 90;
}
return prev + 10;
});
}, 500);
const result = await poiContextApi.detectPOIs(
tenantId,
bakeryLocation.latitude,
bakeryLocation.longitude,
false
);
clearInterval(progressInterval);
setProgress(100);
setDetectionResult(result);
} catch (err: any) {
setError(err.message || 'Failed to detect POIs');
console.error('POI detection error:', err);
} finally {
setIsDetecting(false);
}
};
// Loading state
if (isDetecting) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Detecting Nearby Points of Interest
</CardTitle>
<CardDescription>
Analyzing your bakery's location to identify nearby schools, offices, transport hubs, and more...
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="h-16 w-16 animate-spin text-blue-600 mb-4" />
<div className="text-center">
<div className="text-lg font-medium mb-2">
Scanning OpenStreetMap data...
</div>
<div className="text-sm text-gray-600">
This may take a few moments
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>Detection Progress</span>
<span>{progress}%</span>
</div>
<Progress value={progress} className="h-2" />
</div>
<div className="grid grid-cols-3 gap-3">
{Object.values(POI_CATEGORY_METADATA).slice(0, 9).map(category => (
<div key={category.name} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<span style={{ fontSize: '20px' }}>{category.icon}</span>
<span className="text-xs text-gray-700">{category.displayName}</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
// Error state
if (error && !detectionResult) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<AlertCircle className="h-5 w-5" />
POI Detection Failed
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
<div className="flex gap-3">
<Button
onClick={handleDetectPOIs}
variant="outline"
className="flex-1"
disabled={isDetecting}
>
{isDetecting ? 'Detecting...' : 'Try Again'}
</Button>
<Button
onClick={() => onComplete?.({ poi_detection_skipped: true })}
variant="ghost"
className="flex-1"
>
Skip for Now
</Button>
</div>
</CardContent>
</Card>
);
}
// Success state
if (detectionResult) {
const { poi_context, competitive_insights } = detectionResult;
const categoriesWithPOIs = Object.entries(poi_context.poi_detection_results)
.filter(([_, data]) => data.count > 0)
.sort((a, b) => (b[1].features?.proximity_score || 0) - (a[1].features?.proximity_score || 0));
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-600">
<CheckCircle className="h-5 w-5" />
POI Detection Complete
</CardTitle>
<CardDescription>
Successfully detected {poi_context.total_pois_detected} points of interest around your bakery
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Summary */}
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-blue-50 rounded-lg text-center">
<div className="text-3xl font-bold text-blue-600">
{poi_context.total_pois_detected}
</div>
<div className="text-sm text-gray-700 mt-1">Total POIs</div>
</div>
<div className="p-4 bg-green-50 rounded-lg text-center">
<div className="text-3xl font-bold text-green-600">
{poi_context.relevant_categories?.length || 0}
</div>
<div className="text-sm text-gray-700 mt-1">Relevant Categories</div>
</div>
<div className="p-4 bg-purple-50 rounded-lg text-center">
<div className="text-3xl font-bold text-purple-600">
{Object.keys(poi_context.ml_features || {}).length}
</div>
<div className="text-sm text-gray-700 mt-1">ML Features</div>
</div>
</div>
{/* Competitive Insights */}
{competitive_insights && competitive_insights.length > 0 && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
<div className="font-semibold mb-2">Location Insights</div>
<ul className="space-y-1 text-sm">
{competitive_insights.map((insight, index) => (
<li key={index}>{insight}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{/* High Impact Categories */}
{poi_context.high_impact_categories && poi_context.high_impact_categories.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700 mb-3">
High Impact Factors for Your Location
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{poi_context.high_impact_categories.map(category => {
const metadata = (POI_CATEGORY_METADATA as Record<string, any>)[category];
if (!metadata) return null;
const categoryData = poi_context.poi_detection_results[category];
return (
<div
key={category}
className="p-3 border border-green-200 bg-green-50 rounded-lg"
>
<div className="flex items-center gap-2 mb-1">
<span style={{ fontSize: '24px' }}>{metadata.icon}</span>
<span className="font-medium text-sm">{metadata.displayName}</span>
</div>
<div className="text-xs text-gray-700">
{categoryData.count} {categoryData.count === 1 ? 'location' : 'locations'}
</div>
</div>
);
})}
</div>
</div>
)}
{/* All Categories */}
{categoriesWithPOIs.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700 mb-3">
All Detected Categories
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{categoriesWithPOIs.map(([category, data]) => {
const metadata = (POI_CATEGORY_METADATA as Record<string, any>)[category];
if (!metadata) return null;
return (
<div
key={category}
className="flex items-center gap-2 p-2 bg-gray-50 rounded"
>
<span style={{ fontSize: '20px' }}>{metadata.icon}</span>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">
{metadata.displayName}
</div>
<div className="text-xs text-gray-600">
{data.count} nearby
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Actions */}
<div className="pt-4 border-t border-gray-200">
<Button
onClick={() => onComplete?.({
poi_detection_completed: true,
total_pois_detected: poi_context.total_pois_detected,
relevant_categories: poi_context.relevant_categories,
high_impact_categories: poi_context.high_impact_categories,
})}
size="lg"
className="w-full"
>
Continue to Next Step
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<p className="text-xs text-center text-gray-600 mt-3">
These location-based features will enhance your demand forecasting accuracy
</p>
</div>
</CardContent>
</Card>
);
}
// Default state - show loading or instruction if needed
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Preparing POI Detection
</CardTitle>
<CardDescription>
Preparing to analyze nearby points of interest around your bakery
</CardDescription>
</CardHeader>
<CardContent className="text-center py-8">
<div className="text-gray-600 mb-4">
{error || 'Waiting for location data...'}
</div>
{error && !tenantId && !bakeryLocation && (
<p className="text-sm text-gray-600">
Please complete the previous step to provide location information.
</p>
)}
</CardContent>
</Card>
);
};

View File

@@ -1,10 +1,10 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Button } from '../../../ui/Button';
import { Input } from '../../../ui/Input';
import React, { useState } from 'react';
import { Button, Input } from '../../../ui';
import { AddressAutocomplete } from '../../../ui/AddressAutocomplete';
import { useRegisterBakery } from '../../../../api/hooks/tenant';
import { BakeryRegistration } from '../../../../api/types/tenant';
import { nominatimService, NominatimResult } from '../../../../api/services/nominatim';
import { debounce } from 'lodash';
import { AddressResult } from '../../../../services/api/geocodingApi';
import { useWizardContext } from '../context';
interface RegisterTenantStepProps {
onNext: () => void;
@@ -18,6 +18,7 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
onComplete,
isFirstStep
}) => {
const wizardContext = useWizardContext();
const [formData, setFormData] = useState<BakeryRegistration>({
name: '',
address: '',
@@ -29,51 +30,14 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [addressSuggestions, setAddressSuggestions] = useState<NominatimResult[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const registerBakery = useRegisterBakery();
// Debounced address search
const searchAddress = useCallback(
debounce(async (query: string) => {
if (query.length < 3) {
setAddressSuggestions([]);
return;
}
setIsSearching(true);
try {
const results = await nominatimService.searchAddress(query);
setAddressSuggestions(results);
setShowSuggestions(true);
} catch (error) {
console.error('Address search failed:', error);
} finally {
setIsSearching(false);
}
}, 500),
[]
);
// Cleanup debounce on unmount
useEffect(() => {
return () => {
searchAddress.cancel();
};
}, [searchAddress]);
const handleInputChange = (field: keyof BakeryRegistration, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}));
// Trigger address search when address field changes
if (field === 'address') {
searchAddress(value);
}
if (errors[field]) {
setErrors(prev => ({
...prev,
@@ -82,18 +46,20 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
}
};
const handleAddressSelect = (result: NominatimResult) => {
const parsed = nominatimService.parseAddress(result);
const handleAddressSelect = (address: AddressResult) => {
setFormData(prev => ({
...prev,
address: parsed.street,
city: parsed.city,
postal_code: parsed.postalCode,
address: address.display_name,
city: address.address.city || address.address.municipality || address.address.suburb || prev.city,
postal_code: address.address.postcode || prev.postal_code,
}));
};
setShowSuggestions(false);
setAddressSuggestions([]);
const handleCoordinatesChange = (lat: number, lon: number) => {
// Store coordinates in the wizard context immediately
// This allows the POI detection step to access location information when it's available
wizardContext.updateLocation({ latitude: lat, longitude: lon });
console.log('Coordinates captured and stored:', { latitude: lat, longitude: lon });
};
const validateForm = () => {
@@ -145,7 +111,14 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
try {
const tenant = await registerBakery.mutateAsync(formData);
onComplete({ tenant });
// Update the wizard context with tenant info and pass the bakeryLocation coordinates
// that were captured during address selection to the next step (POI Detection)
onComplete({
tenant,
tenantId: tenant.id,
bakeryLocation: wizardContext.state.bakeryLocation
});
} catch (error) {
console.error('Error registering bakery:', error);
setErrors({ submit: 'Error al registrar la panadería. Por favor, inténtalo de nuevo.' });
@@ -174,41 +147,24 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
isRequired
/>
<div className="md:col-span-2 relative">
<Input
label="Dirección"
placeholder="Calle Principal 123, Madrid"
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Dirección <span className="text-red-500">*</span>
</label>
<AddressAutocomplete
value={formData.address}
onChange={(e) => handleInputChange('address', e.target.value)}
onFocus={() => {
if (addressSuggestions.length > 0) {
setShowSuggestions(true);
}
placeholder="Enter bakery address..."
onAddressSelect={(address) => {
console.log('Selected:', address.display_name);
handleAddressSelect(address);
}}
onBlur={() => {
setTimeout(() => setShowSuggestions(false), 200);
}}
error={errors.address}
isRequired
onCoordinatesChange={handleCoordinatesChange}
countryCode="es"
required
/>
{isSearching && (
<div className="absolute right-3 top-10 text-gray-400">
Buscando...
</div>
)}
{showSuggestions && addressSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{addressSuggestions.map((result) => (
<div
key={result.place_id}
className="px-4 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0"
onClick={() => handleAddressSelect(result)}
>
<div className="text-sm font-medium text-gray-900">
{nominatimService.formatAddress(result)}
</div>
</div>
))}
{errors.address && (
<div className="mt-1 text-sm text-red-600">
{errors.address}
</div>
)}
</div>

View File

@@ -4,6 +4,7 @@ export { default as DataSourceChoiceStep } from './DataSourceChoiceStep';
// Core Onboarding Steps
export { RegisterTenantStep } from './RegisterTenantStep';
export { POIDetectionStep } from './POIDetectionStep';
// Sales Data & Inventory (REFACTORED - split from UploadSalesDataStep)
export { FileUploadStep } from './FileUploadStep';

View File

@@ -0,0 +1,256 @@
import React, { useState } from 'react';
import { RefreshCw, CheckCircle, AlertCircle, Clock, TrendingUp, Loader2 } from 'lucide-react';
import { Card } from '../../ui';
import { showToast } from '../../../utils/toast';
import { posService } from '../../../api/services/pos';
interface POSSyncStatusProps {
tenantId: string;
onSyncComplete?: () => void;
}
interface SyncStatus {
total_completed_transactions: number;
synced_to_sales: number;
pending_sync: number;
sync_rate: number;
}
export const POSSyncStatus: React.FC<POSSyncStatusProps> = ({ tenantId, onSyncComplete }) => {
const [status, setStatus] = useState<SyncStatus | null>(null);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const fetchSyncStatus = async () => {
setLoading(true);
try {
const response = await fetch(
`/api/v1/pos/tenants/${tenantId}/pos/transactions/sync-status`,
{
headers: {
'Content-Type': 'application/json',
// Add auth headers as needed
},
}
);
if (!response.ok) {
throw new Error('Failed to fetch sync status');
}
const data = await response.json();
setStatus(data);
setLastUpdated(new Date());
} catch (error: any) {
console.error('Error fetching sync status:', error);
showToast.error('Error al obtener estado de sincronización');
} finally {
setLoading(false);
}
};
const triggerSync = async () => {
setSyncing(true);
try {
const response = await fetch(
`/api/v1/pos/tenants/${tenantId}/pos/transactions/sync-all-to-sales`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Add auth headers as needed
},
}
);
if (!response.ok) {
throw new Error('Sync failed');
}
const result = await response.json();
showToast.success(
`Sincronización completada: ${result.synced} de ${result.total_transactions} transacciones`
);
// Refresh status after sync
await fetchSyncStatus();
if (onSyncComplete) {
onSyncComplete();
}
} catch (error: any) {
console.error('Error during sync:', error);
showToast.error('Error al sincronizar transacciones');
} finally {
setSyncing(false);
}
};
React.useEffect(() => {
if (tenantId) {
fetchSyncStatus();
// Auto-refresh every 30 seconds
const interval = setInterval(fetchSyncStatus, 30000);
return () => clearInterval(interval);
}
}, [tenantId]);
if (loading && !status) {
return (
<Card className="p-6">
<div className="flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-[var(--color-primary)]" />
<span className="ml-3 text-[var(--text-secondary)]">Cargando estado...</span>
</div>
</Card>
);
}
if (!status) {
return null;
}
const hasPendingSync = status.pending_sync > 0;
return (
<Card className="p-6">
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
<RefreshCw className="w-5 h-5 mr-2 text-blue-500" />
Estado de Sincronización POS Ventas
</h3>
<button
onClick={fetchSyncStatus}
disabled={loading}
className="p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors disabled:opacity-50"
title="Actualizar estado"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Total Transactions */}
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-[var(--text-secondary)]">Total Transacciones</span>
<Clock className="w-4 h-4 text-gray-400" />
</div>
<div className="text-2xl font-bold text-[var(--text-primary)]">
{status.total_completed_transactions}
</div>
</div>
{/* Synced */}
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-green-700 dark:text-green-400">Sincronizadas</span>
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400" />
</div>
<div className="text-2xl font-bold text-green-800 dark:text-green-300">
{status.synced_to_sales}
</div>
</div>
{/* Pending */}
<div
className={`p-4 rounded-lg ${
hasPendingSync
? 'bg-orange-50 dark:bg-orange-900/20'
: 'bg-gray-50 dark:bg-gray-800'
}`}
>
<div className="flex items-center justify-between mb-2">
<span
className={`text-sm ${
hasPendingSync
? 'text-orange-700 dark:text-orange-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
Pendientes
</span>
<AlertCircle
className={`w-4 h-4 ${
hasPendingSync
? 'text-orange-600 dark:text-orange-400'
: 'text-gray-400'
}`}
/>
</div>
<div
className={`text-2xl font-bold ${
hasPendingSync
? 'text-orange-800 dark:text-orange-300'
: 'text-gray-600 dark:text-gray-300'
}`}
>
{status.pending_sync}
</div>
</div>
</div>
{/* Sync Rate */}
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-[var(--text-secondary)]">Tasa de Sincronización</span>
<TrendingUp className="w-4 h-4 text-blue-500" />
</div>
<div className="flex items-center">
<div className="flex-1">
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${status.sync_rate}%` }}
/>
</div>
</div>
<span className="ml-3 text-lg font-semibold text-[var(--text-primary)]">
{status.sync_rate.toFixed(1)}%
</span>
</div>
</div>
{/* Actions */}
{hasPendingSync && (
<div className="flex items-center justify-between pt-4 border-t border-[var(--border-primary)]">
<div className="text-sm text-[var(--text-secondary)]">
{status.pending_sync} transacción{status.pending_sync !== 1 ? 'es' : ''} esperando
sincronización
</div>
<button
onClick={triggerSync}
disabled={syncing}
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary)]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors inline-flex items-center gap-2"
>
{syncing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Sincronizando...
</>
) : (
<>
<RefreshCw className="w-4 h-4" />
Sincronizar Ahora
</>
)}
</button>
</div>
)}
{/* Last Updated */}
{lastUpdated && (
<div className="text-xs text-[var(--text-tertiary)] text-center">
Última actualización: {lastUpdated.toLocaleTimeString('es-ES')}
</div>
)}
</div>
</Card>
);
};

View File

@@ -0,0 +1,499 @@
// ================================================================
// frontend/src/components/domain/procurement/DeliveryReceiptModal.tsx
// ================================================================
/**
* Delivery Receipt Modal
*
* Modal for recording delivery receipt with:
* - Item-by-item quantity verification
* - Batch/lot number entry
* - Expiration date entry
* - Quality inspection toggle
* - Rejection reasons for damaged/incorrect items
*/
import React, { useState, useMemo } from 'react';
import {
X,
Package,
Calendar,
Hash,
CheckCircle2,
AlertTriangle,
Truck,
ClipboardCheck,
} from 'lucide-react';
// Define delivery item type
interface DeliveryItemInput {
purchase_order_item_id: string;
inventory_product_id: string;
product_name: string;
ordered_quantity: number;
unit_of_measure: string;
delivered_quantity: number;
accepted_quantity: number;
rejected_quantity: number;
batch_lot_number?: string;
expiry_date?: string;
quality_issues?: string;
rejection_reason?: string;
}
interface DeliveryReceiptModalProps {
isOpen: boolean;
onClose: () => void;
purchaseOrder: {
id: string;
po_number: string;
supplier_id: string;
supplier_name?: string;
items: Array<{
id: string;
inventory_product_id: string;
product_name: string;
ordered_quantity: number;
unit_of_measure: string;
received_quantity: number;
}>;
};
onSubmit: (deliveryData: {
purchase_order_id: string;
supplier_id: string;
items: DeliveryItemInput[];
inspection_passed: boolean;
inspection_notes?: string;
notes?: string;
}) => Promise<void>;
loading?: boolean;
}
export function DeliveryReceiptModal({
isOpen,
onClose,
purchaseOrder,
onSubmit,
loading = false,
}: DeliveryReceiptModalProps) {
const [items, setItems] = useState<DeliveryItemInput[]>(() =>
purchaseOrder.items.map(item => ({
purchase_order_item_id: item.id,
inventory_product_id: item.inventory_product_id,
product_name: item.product_name,
ordered_quantity: item.ordered_quantity,
unit_of_measure: item.unit_of_measure,
delivered_quantity: item.ordered_quantity - item.received_quantity, // Remaining qty
accepted_quantity: item.ordered_quantity - item.received_quantity,
rejected_quantity: 0,
batch_lot_number: '',
expiry_date: '',
quality_issues: '',
rejection_reason: '',
}))
);
const [inspectionPassed, setInspectionPassed] = useState(true);
const [inspectionNotes, setInspectionNotes] = useState('');
const [generalNotes, setGeneralNotes] = useState('');
// Calculate summary statistics
const summary = useMemo(() => {
const totalOrdered = items.reduce((sum, item) => sum + item.ordered_quantity, 0);
const totalDelivered = items.reduce((sum, item) => sum + item.delivered_quantity, 0);
const totalAccepted = items.reduce((sum, item) => sum + item.accepted_quantity, 0);
const totalRejected = items.reduce((sum, item) => sum + item.rejected_quantity, 0);
const hasIssues = items.some(item => item.rejected_quantity > 0 || item.quality_issues);
return {
totalOrdered,
totalDelivered,
totalAccepted,
totalRejected,
hasIssues,
completionRate: totalOrdered > 0 ? (totalAccepted / totalOrdered) * 100 : 0,
};
}, [items]);
const updateItem = (index: number, field: keyof DeliveryItemInput, value: any) => {
setItems(prevItems => {
const newItems = [...prevItems];
newItems[index] = { ...newItems[index], [field]: value };
// Auto-calculate accepted quantity when delivered or rejected changes
if (field === 'delivered_quantity' || field === 'rejected_quantity') {
const delivered = field === 'delivered_quantity' ? value : newItems[index].delivered_quantity;
const rejected = field === 'rejected_quantity' ? value : newItems[index].rejected_quantity;
newItems[index].accepted_quantity = Math.max(0, delivered - rejected);
}
return newItems;
});
};
const handleSubmit = async () => {
// Validate that all items have required fields
const hasErrors = items.some(item =>
item.delivered_quantity < 0 ||
item.accepted_quantity < 0 ||
item.rejected_quantity < 0 ||
item.delivered_quantity < item.rejected_quantity
);
if (hasErrors) {
alert('Please fix validation errors before submitting');
return;
}
const deliveryData = {
purchase_order_id: purchaseOrder.id,
supplier_id: purchaseOrder.supplier_id,
items: items.filter(item => item.delivered_quantity > 0), // Only include delivered items
inspection_passed: inspectionPassed,
inspection_notes: inspectionNotes || undefined,
notes: generalNotes || undefined,
};
await onSubmit(deliveryData);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
{/* Header */}
<div
className="p-6 border-b flex items-center justify-between"
style={{ borderColor: 'var(--border-primary)' }}
>
<div className="flex items-center gap-3">
<Truck className="w-6 h-6" style={{ color: 'var(--color-info-600)' }} />
<div>
<h2 className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>
Record Delivery Receipt
</h2>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
PO #{purchaseOrder.po_number} {purchaseOrder.supplier_name}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-opacity-80 transition-colors"
style={{ backgroundColor: 'var(--bg-secondary)' }}
disabled={loading}
>
<X className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
{/* Summary Stats */}
<div
className="p-4 border-b grid grid-cols-4 gap-4"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)'
}}
>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Ordered
</p>
<p className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>
{summary.totalOrdered.toFixed(1)}
</p>
</div>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Delivered
</p>
<p className="text-lg font-bold" style={{ color: 'var(--color-info-600)' }}>
{summary.totalDelivered.toFixed(1)}
</p>
</div>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Accepted
</p>
<p className="text-lg font-bold" style={{ color: 'var(--color-success-600)' }}>
{summary.totalAccepted.toFixed(1)}
</p>
</div>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Rejected
</p>
<p className="text-lg font-bold" style={{ color: 'var(--color-error-600)' }}>
{summary.totalRejected.toFixed(1)}
</p>
</div>
</div>
{/* Items List */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{items.map((item, index) => (
<div
key={item.purchase_order_item_id}
className="border rounded-lg p-4 space-y-3"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: item.rejected_quantity > 0
? 'var(--color-error-300)'
: 'var(--border-primary)',
}}
>
{/* Item Header */}
<div className="flex items-start justify-between">
<div className="flex items-start gap-2 flex-1">
<Package className="w-5 h-5 mt-0.5" style={{ color: 'var(--color-info-600)' }} />
<div>
<p className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{item.product_name}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Ordered: {item.ordered_quantity} {item.unit_of_measure}
</p>
</div>
</div>
</div>
{/* Quantity Inputs */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Delivered Qty *
</label>
<input
type="number"
value={item.delivered_quantity}
onChange={(e) => updateItem(index, 'delivered_quantity', parseFloat(e.target.value) || 0)}
min="0"
step="0.01"
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Rejected Qty
</label>
<input
type="number"
value={item.rejected_quantity}
onChange={(e) => updateItem(index, 'rejected_quantity', parseFloat(e.target.value) || 0)}
min="0"
step="0.01"
max={item.delivered_quantity}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: item.rejected_quantity > 0 ? 'var(--color-error-300)' : 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Accepted Qty
</label>
<input
type="number"
value={item.accepted_quantity}
readOnly
className="w-full px-3 py-2 rounded border text-sm font-semibold"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
color: 'var(--color-success-600)',
}}
/>
</div>
</div>
{/* Batch & Expiry */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="flex items-center gap-1 text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<Hash className="w-3 h-3" />
Batch/Lot Number
</label>
<input
type="text"
value={item.batch_lot_number}
onChange={(e) => updateItem(index, 'batch_lot_number', e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
<div>
<label className="flex items-center gap-1 text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<Calendar className="w-3 h-3" />
Expiration Date
</label>
<input
type="date"
value={item.expiry_date}
onChange={(e) => updateItem(index, 'expiry_date', e.target.value)}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
</div>
{/* Quality Issues / Rejection Reason */}
{item.rejected_quantity > 0 && (
<div>
<label className="flex items-center gap-1 text-xs font-medium mb-1" style={{ color: 'var(--color-error-600)' }}>
<AlertTriangle className="w-3 h-3" />
Rejection Reason *
</label>
<textarea
value={item.rejection_reason}
onChange={(e) => updateItem(index, 'rejection_reason', e.target.value)}
placeholder="Why was this item rejected? (damaged, wrong product, quality issues, etc.)"
rows={2}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--color-error-300)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
)}
</div>
))}
</div>
{/* Quality Inspection */}
<div
className="p-4 border-t space-y-3"
style={{ borderColor: 'var(--border-primary)' }}
>
<div className="flex items-center gap-3">
<ClipboardCheck className="w-5 h-5" style={{ color: 'var(--color-info-600)' }} />
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={inspectionPassed}
onChange={(e) => setInspectionPassed(e.target.checked)}
className="w-4 h-4"
disabled={loading}
/>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
Quality inspection passed
</span>
</label>
</div>
{!inspectionPassed && (
<textarea
value={inspectionNotes}
onChange={(e) => setInspectionNotes(e.target.value)}
placeholder="Describe quality inspection issues..."
rows={2}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--color-warning-300)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
)}
<textarea
value={generalNotes}
onChange={(e) => setGeneralNotes(e.target.value)}
placeholder="General delivery notes (optional)"
rows={2}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
{/* Footer Actions */}
<div
className="p-6 border-t flex items-center justify-between"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)'
}}
>
<div>
{summary.hasIssues && (
<p className="text-sm flex items-center gap-2" style={{ color: 'var(--color-warning-700)' }}>
<AlertTriangle className="w-4 h-4" />
This delivery has quality issues or rejections
</p>
)}
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg font-medium transition-colors"
style={{
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-secondary)',
border: '1px solid var(--border-primary)',
}}
disabled={loading}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading || summary.totalDelivered === 0}
className="px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2"
style={{
backgroundColor: loading ? 'var(--color-info-300)' : 'var(--color-info-600)',
color: 'white',
opacity: loading || summary.totalDelivered === 0 ? 0.6 : 1,
cursor: loading || summary.totalDelivered === 0 ? 'not-allowed' : 'pointer',
}}
>
{loading ? (
<>Processing...</>
) : (
<>
<CheckCircle2 className="w-4 h-4" />
Record Delivery
</>
)}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,3 +1,4 @@
// Procurement Components - Components for procurement and purchase order management
export { default as CreatePurchaseOrderModal } from './CreatePurchaseOrderModal';
export { default as CreatePurchaseOrderModal } from './CreatePurchaseOrderModal';
export { DeliveryReceiptModal } from './DeliveryReceiptModal';

View File

@@ -0,0 +1,231 @@
/**
* POI Category Accordion Component
*
* Expandable accordion showing detailed POI information by category
*/
import React, { useState } from 'react';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/Accordion';
import { Badge } from '@/components/ui/Badge';
import { Progress } from '@/components/ui/Progress';
import type { POIContext, POICategoryData } from '@/types/poi';
import { POI_CATEGORY_METADATA, formatDistance, getImpactLevel, IMPACT_LEVELS } from '@/types/poi';
interface POICategoryAccordionProps {
poiContext: POIContext;
selectedCategory?: string | null;
onCategorySelect?: (category: string | null) => void;
}
export const POICategoryAccordion: React.FC<POICategoryAccordionProps> = ({
poiContext,
selectedCategory,
onCategorySelect
}) => {
// Sort categories by proximity score (descending)
const sortedCategories = Object.entries(poiContext.poi_detection_results)
.filter(([_, data]) => data.count > 0)
.sort((a, b) => b[1].features.proximity_score - a[1].features.proximity_score);
const renderCategoryDetails = (category: string, data: POICategoryData) => {
const { features } = data;
const impactLevel = getImpactLevel(features.proximity_score);
const impactConfig = IMPACT_LEVELS[impactLevel];
return (
<div className="space-y-4">
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<div className="text-xs text-gray-600 mb-1">Total Count</div>
<div className="text-2xl font-bold">{features.total_count}</div>
</div>
<div>
<div className="text-xs text-gray-600 mb-1">Proximity Score</div>
<div className="text-2xl font-bold text-blue-600">
{features.proximity_score.toFixed(2)}
</div>
</div>
<div>
<div className="text-xs text-gray-600 mb-1">Nearest</div>
<div className="text-lg font-semibold">
{formatDistance(features.distance_to_nearest_m)}
</div>
</div>
<div>
<div className="text-xs text-gray-600 mb-1">Impact Level</div>
<Badge
variant={impactLevel === 'HIGH' ? 'success' : impactLevel === 'MODERATE' ? 'warning' : 'secondary'}
>
{impactConfig.label}
</Badge>
</div>
</div>
{/* Distance Distribution */}
<div>
<div className="text-sm font-semibold text-gray-700 mb-3">
Distance Distribution
</div>
<div className="space-y-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span>0-100m (Immediate)</span>
<span className="font-medium">{features.count_0_100m}</span>
</div>
<Progress
value={(features.count_0_100m / features.total_count) * 100}
className="h-2"
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>100-300m (Primary)</span>
<span className="font-medium">{features.count_100_300m}</span>
</div>
<Progress
value={(features.count_100_300m / features.total_count) * 100}
className="h-2"
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>300-500m (Secondary)</span>
<span className="font-medium">{features.count_300_500m}</span>
</div>
<Progress
value={(features.count_300_500m / features.total_count) * 100}
className="h-2"
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>500-1000m (Tertiary)</span>
<span className="font-medium">{features.count_500_1000m}</span>
</div>
<Progress
value={(features.count_500_1000m / features.total_count) * 100}
className="h-2"
/>
</div>
</div>
</div>
{/* POI List */}
{data.pois.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700 mb-2">
Locations ({data.pois.length})
</div>
<div className="max-h-64 overflow-y-auto space-y-2">
{data.pois.map((poi, index) => (
<div
key={`${poi.osm_id}-${index}`}
className="p-2 border border-gray-200 rounded hover:bg-gray-50 transition-colors"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="font-medium text-sm">{poi.name}</div>
{poi.tags.addr_street && (
<div className="text-xs text-gray-600 mt-1">
{poi.tags.addr_street}
{poi.tags.addr_housenumber && ` ${poi.tags.addr_housenumber}`}
</div>
)}
</div>
{poi.distance_m !== undefined && (
<Badge variant="outline" className="text-xs ml-2">
{formatDistance(poi.distance_m)}
</Badge>
)}
</div>
{poi.zone && (
<div className="text-xs text-gray-500 mt-1">
Zone: {poi.zone.replace('_', ' ')}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Error Message */}
{data.error && (
<div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
Error: {data.error}
</div>
)}
</div>
);
};
if (sortedCategories.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No POIs detected in any category
</div>
);
}
return (
<div className="space-y-2">
<Accordion type="single" collapsible className="w-full">
{sortedCategories.map(([category, data]) => {
const metadata = POI_CATEGORY_METADATA[category];
if (!metadata) return null;
const impactLevel = getImpactLevel(data.features.proximity_score);
const impactConfig = IMPACT_LEVELS[impactLevel];
const isSelected = selectedCategory === category;
return (
<AccordionItem key={category} value={category}>
<AccordionTrigger
className={`hover:no-underline ${isSelected ? 'bg-blue-50' : ''}`}
onClick={() => onCategorySelect?.(isSelected ? null : category)}
>
<div className="flex items-center justify-between w-full pr-4">
<div className="flex items-center gap-3">
<span style={{ fontSize: '24px' }}>{metadata.icon}</span>
<div className="text-left">
<div className="font-semibold">{metadata.displayName}</div>
<div className="text-xs text-gray-600 font-normal">
{data.count} {data.count === 1 ? 'location' : 'locations'}
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Badge
variant={impactLevel === 'HIGH' ? 'success' : impactLevel === 'MODERATE' ? 'warning' : 'secondary'}
>
{impactConfig.label}
</Badge>
<div className="text-right">
<div className="text-sm font-medium">
Score: {data.features.proximity_score.toFixed(2)}
</div>
<div className="text-xs text-gray-600">
Nearest: {formatDistance(data.features.distance_to_nearest_m)}
</div>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="pt-4 pb-2 px-2">
{renderCategoryDetails(category, data)}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
);
};

View File

@@ -0,0 +1,314 @@
/**
* POI Context View Component
*
* Main view for POI detection results and management
* Displays map, summary, and detailed category information
*/
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardContent, CardTitle, CardDescription } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Alert, AlertDescription } from '@/components/ui/Alert';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs';
import { RefreshCw, AlertCircle, CheckCircle, MapPin } from 'lucide-react';
import { POIMap } from './POIMap';
import { POISummaryCard } from './POISummaryCard';
import { POICategoryAccordion } from './POICategoryAccordion';
import { usePOIContext } from '@/hooks/usePOIContext';
import { Loader } from '@/components/ui/Loader';
interface POIContextViewProps {
tenantId: string;
bakeryLocation?: {
latitude: number;
longitude: number;
};
}
export const POIContextView: React.FC<POIContextViewProps> = ({
tenantId,
bakeryLocation
}) => {
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('overview');
const {
poiContext,
isLoading,
isRefreshing,
error,
isStale,
needsRefresh,
competitorAnalysis,
competitiveInsights,
detectPOIs,
refreshPOIs,
fetchContext,
fetchCompetitorAnalysis
} = usePOIContext({ tenantId, autoFetch: true });
// Fetch competitor analysis when POI context is available
useEffect(() => {
if (poiContext && !competitorAnalysis) {
fetchCompetitorAnalysis();
}
}, [poiContext, competitorAnalysis, fetchCompetitorAnalysis]);
// Handle initial POI detection if no context exists
const handleInitialDetection = async () => {
if (!bakeryLocation) {
return;
}
await detectPOIs(bakeryLocation.latitude, bakeryLocation.longitude);
};
// Handle POI refresh
const handleRefresh = async () => {
await refreshPOIs();
};
if (isLoading && !poiContext) {
return (
<Card>
<CardContent className="py-12">
<Loader size="large" text="Loading POI context..." />
</CardContent>
</Card>
);
}
// No POI context - show detection prompt
if (!poiContext && !error) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Location Context
</CardTitle>
<CardDescription>
Detect nearby points of interest to enhance demand forecasting accuracy
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
POI detection has not been run for this location. Click the button below to
automatically detect nearby schools, offices, transport hubs, and other
points of interest that may affect bakery demand.
</AlertDescription>
</Alert>
{bakeryLocation && (
<div className="p-4 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-2">
Bakery Location
</div>
<div className="text-sm text-gray-600">
Latitude: {bakeryLocation.latitude.toFixed(6)}
</div>
<div className="text-sm text-gray-600">
Longitude: {bakeryLocation.longitude.toFixed(6)}
</div>
</div>
)}
<Button
onClick={handleInitialDetection}
disabled={!bakeryLocation || isLoading}
size="lg"
className="w-full"
>
{isLoading ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Detecting POIs...
</>
) : (
<>
<MapPin className="mr-2 h-4 w-4" />
Detect Points of Interest
</>
)}
</Button>
{!bakeryLocation && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Bakery location is required for POI detection. Please ensure your
bakery address has been geocoded.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
}
// Error state
if (error && !poiContext) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<AlertCircle className="h-5 w-5" />
Error Loading POI Context
</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button onClick={fetchContext} className="mt-4">
Retry
</Button>
</CardContent>
</Card>
);
}
if (!poiContext) return null;
return (
<div className="space-y-6">
{/* Header */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Location Context & POI Analysis
</CardTitle>
<CardDescription>
Detected {poiContext.total_pois_detected} points of interest around your bakery
</CardDescription>
</div>
<div className="flex gap-2">
{(isStale || needsRefresh) && (
<Alert variant="warning" className="mb-0">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
POI data may be outdated
</AlertDescription>
</Alert>
)}
<Button
onClick={handleRefresh}
disabled={isRefreshing}
variant="outline"
size="sm"
>
{isRefreshing ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Refreshing...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh POI Data
</>
)}
</Button>
</div>
</div>
</CardHeader>
</Card>
{/* Competitive Insights */}
{competitiveInsights && competitiveInsights.length > 0 && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
<div className="font-semibold mb-2">Competitive Analysis</div>
<ul className="space-y-1 text-sm">
{competitiveInsights.map((insight, index) => (
<li key={index}>{insight}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{/* Main Content */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="overview">Overview & Map</TabsTrigger>
<TabsTrigger value="categories">Detailed Categories</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Map */}
<div className="lg:col-span-2 h-[600px]">
<POIMap
poiContext={poiContext}
selectedCategory={selectedCategory}
/>
</div>
{/* Summary */}
<div className="lg:col-span-1">
<POISummaryCard
poiContext={poiContext}
onCategorySelect={setSelectedCategory}
/>
</div>
</div>
</TabsContent>
<TabsContent value="categories" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>POI Categories</CardTitle>
<CardDescription>
Detailed breakdown of detected points of interest by category
</CardDescription>
</CardHeader>
<CardContent>
<POICategoryAccordion
poiContext={poiContext}
selectedCategory={selectedCategory}
onCategorySelect={setSelectedCategory}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Detection Metadata */}
<Card>
<CardHeader>
<CardTitle className="text-sm">Detection Metadata</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-gray-600">Detection Date</div>
<div className="font-medium">
{new Date(poiContext.detection_timestamp).toLocaleDateString()}
</div>
</div>
<div>
<div className="text-gray-600">Source</div>
<div className="font-medium capitalize">{poiContext.detection_source}</div>
</div>
<div>
<div className="text-gray-600">Relevant Categories</div>
<div className="font-medium">{poiContext.relevant_categories?.length || 0}</div>
</div>
<div>
<div className="text-gray-600">ML Features</div>
<div className="font-medium">
{Object.keys(poiContext.ml_features || {}).length}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,201 @@
/**
* POI Map Component
*
* Interactive map visualization of POIs around bakery location
* Uses Leaflet for mapping and displays POIs with color-coded markers
*/
import React, { useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Circle, Popup, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { POIContext, POI } from '@/types/poi';
import { POI_CATEGORY_METADATA, formatDistance } from '@/types/poi';
// Fix for default marker icons in Leaflet
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
});
interface POIMapProps {
poiContext: POIContext;
selectedCategory?: string | null;
}
// Helper component to create custom colored icons
function createColoredIcon(color: string, emoji: string): L.DivIcon {
return L.divIcon({
className: 'custom-poi-marker',
html: `<div style="background-color: ${color}; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3); font-size: 18px;">${emoji}</div>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
});
}
function createBakeryIcon(): L.DivIcon {
return L.divIcon({
className: 'bakery-marker',
html: `<div style="background-color: #dc2626; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: 3px solid white; box-shadow: 0 3px 6px rgba(0,0,0,0.4); font-size: 24px;">🏪</div>`,
iconSize: [40, 40],
iconAnchor: [20, 20]
});
}
// Component to recenter map when location changes
function MapRecenter({ center }: { center: [number, number] }) {
const map = useMap();
React.useEffect(() => {
map.setView(center, map.getZoom());
}, [center, map]);
return null;
}
export const POIMap: React.FC<POIMapProps> = ({ poiContext, selectedCategory }) => {
const center: [number, number] = [
poiContext.location.latitude,
poiContext.location.longitude
];
// Filter POIs by selected category
const poisToDisplay = useMemo(() => {
const pois: Array<{ category: string; poi: POI }> = [];
Object.entries(poiContext.poi_detection_results).forEach(([category, data]) => {
if (selectedCategory && selectedCategory !== category) {
return; // Skip if category filter is active and doesn't match
}
data.pois.forEach(poi => {
pois.push({ category, poi });
});
});
return pois;
}, [poiContext, selectedCategory]);
return (
<div className="relative w-full h-full rounded-lg overflow-hidden border border-gray-200">
<MapContainer
center={center}
zoom={15}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom={true}
>
<MapRecenter center={center} />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Bakery marker */}
<Marker position={center} icon={createBakeryIcon()}>
<Popup>
<div className="text-center">
<div className="font-semibold text-base">Your Bakery</div>
<div className="text-sm text-gray-600 mt-1">
{center[0].toFixed(6)}, {center[1].toFixed(6)}
</div>
</div>
</Popup>
</Marker>
{/* Distance rings */}
<Circle
center={center}
radius={100}
pathOptions={{
color: '#22c55e',
fillColor: '#22c55e',
fillOpacity: 0.05,
weight: 2,
dashArray: '5, 5'
}}
/>
<Circle
center={center}
radius={300}
pathOptions={{
color: '#f59e0b',
fillColor: '#f59e0b',
fillOpacity: 0.03,
weight: 2,
dashArray: '5, 5'
}}
/>
<Circle
center={center}
radius={500}
pathOptions={{
color: '#ef4444',
fillColor: '#ef4444',
fillOpacity: 0.02,
weight: 2,
dashArray: '5, 5'
}}
/>
{/* POI markers */}
{poisToDisplay.map(({ category, poi }, index) => {
const metadata = POI_CATEGORY_METADATA[category];
if (!metadata) return null;
return (
<Marker
key={`${category}-${poi.osm_id}-${index}`}
position={[poi.lat, poi.lon]}
icon={createColoredIcon(metadata.color, metadata.icon)}
>
<Popup>
<div className="min-w-[200px]">
<div className="flex items-center gap-2 mb-2">
<span style={{ fontSize: '24px' }}>{metadata.icon}</span>
<div>
<div className="font-semibold">{poi.name}</div>
<div className="text-xs text-gray-600">{metadata.displayName}</div>
</div>
</div>
{poi.distance_m && (
<div className="text-sm text-gray-700 mt-1">
Distance: <span className="font-medium">{formatDistance(poi.distance_m)}</span>
</div>
)}
{poi.zone && (
<div className="text-sm text-gray-700">
Zone: <span className="font-medium capitalize">{poi.zone.replace('_', ' ')}</span>
</div>
)}
<div className="text-xs text-gray-500 mt-2">
OSM ID: {poi.osm_id}
</div>
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
{/* Legend */}
<div className="absolute bottom-4 right-4 bg-white rounded-lg shadow-lg p-3 max-w-xs z-[1000]">
<div className="font-semibold text-sm mb-2">Distance Rings</div>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 border-t-2 border-green-500 border-dashed"></div>
<span>100m - Immediate</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 border-t-2 border-orange-500 border-dashed"></div>
<span>300m - Primary</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 border-t-2 border-red-500 border-dashed"></div>
<span>500m - Secondary</span>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,185 @@
/**
* POI Summary Card Component
*
* Displays summary statistics and high-impact categories
*/
import React from 'react';
import { Card, CardHeader, CardContent, CardTitle } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import type { POIContext } from '@/types/poi';
import { POI_CATEGORY_METADATA, getImpactLevel, IMPACT_LEVELS } from '@/types/poi';
interface POISummaryCardProps {
poiContext: POIContext;
onCategorySelect?: (category: string) => void;
}
export const POISummaryCard: React.FC<POISummaryCardProps> = ({
poiContext,
onCategorySelect
}) => {
const highImpactCategories = poiContext.high_impact_categories || [];
const relevantCategories = poiContext.relevant_categories || [];
// Calculate category impact levels
const categoryImpacts = Object.entries(poiContext.poi_detection_results)
.map(([category, data]) => ({
category,
proximityScore: data.features.proximity_score,
count: data.count,
impactLevel: getImpactLevel(data.features.proximity_score)
}))
.filter(item => item.count > 0)
.sort((a, b) => b.proximityScore - a.proximityScore);
const detectionDate = poiContext.detection_timestamp
? new Date(poiContext.detection_timestamp).toLocaleDateString()
: 'Unknown';
const needsRefresh = poiContext.next_refresh_date
? new Date(poiContext.next_refresh_date) < new Date()
: false;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>POI Summary</span>
{needsRefresh && (
<Badge variant="warning" className="text-xs">
Refresh Recommended
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Total POIs */}
<div>
<div className="text-sm text-gray-600 mb-1">Total POIs Detected</div>
<div className="text-3xl font-bold text-gray-900">
{poiContext.total_pois_detected}
</div>
</div>
{/* Detection Info */}
<div className="space-y-1">
<div className="text-sm text-gray-600">Detection Date</div>
<div className="text-sm font-medium">{detectionDate}</div>
</div>
{/* Detection Status */}
<div className="space-y-1">
<div className="text-sm text-gray-600">Status</div>
<Badge
variant={
poiContext.detection_status === 'completed'
? 'success'
: poiContext.detection_status === 'partial'
? 'warning'
: 'destructive'
}
>
{poiContext.detection_status}
</Badge>
</div>
{/* Impact Categories */}
{categoryImpacts.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700 mb-2">
Impact by Category
</div>
<div className="space-y-2">
{categoryImpacts.map(({ category, count, proximityScore, impactLevel }) => {
const metadata = POI_CATEGORY_METADATA[category];
if (!metadata) return null;
const impactConfig = IMPACT_LEVELS[impactLevel];
return (
<div
key={category}
className="flex items-center justify-between p-2 rounded hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => onCategorySelect?.(category)}
>
<div className="flex items-center gap-2">
<span style={{ fontSize: '20px' }}>{metadata.icon}</span>
<div>
<div className="text-sm font-medium">
{metadata.displayName}
</div>
<div className="text-xs text-gray-600">
{count} {count === 1 ? 'location' : 'locations'}
</div>
</div>
</div>
<div className="text-right">
<Badge
variant={impactLevel === 'HIGH' ? 'success' : impactLevel === 'MODERATE' ? 'warning' : 'secondary'}
className="text-xs"
>
{impactConfig.label}
</Badge>
<div className="text-xs text-gray-600 mt-1">
Score: {proximityScore.toFixed(2)}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* High Impact Highlights */}
{highImpactCategories.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700 mb-2">
High Impact Factors
</div>
<div className="flex flex-wrap gap-2">
{highImpactCategories.map(category => {
const metadata = POI_CATEGORY_METADATA[category];
if (!metadata) return null;
return (
<Badge
key={category}
variant="success"
className="cursor-pointer"
onClick={() => onCategorySelect?.(category)}
>
{metadata.icon} {metadata.displayName}
</Badge>
);
})}
</div>
</div>
)}
{/* ML Features Count */}
<div className="pt-3 border-t border-gray-200">
<div className="text-sm text-gray-600">ML Features Generated</div>
<div className="text-2xl font-bold text-blue-600">
{Object.keys(poiContext.ml_features || {}).length}
</div>
<div className="text-xs text-gray-500 mt-1">
Used for demand forecasting
</div>
</div>
{/* Location Coordinates */}
<div className="pt-3 border-t border-gray-200 text-xs text-gray-500">
<div className="font-semibold mb-1">Location</div>
<div>
Lat: {poiContext.location.latitude.toFixed(6)}
</div>
<div>
Lon: {poiContext.location.longitude.toFixed(6)}
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -720,6 +720,7 @@ const ReviewStep: React.FC<EntryMethodStepProps> = ({ data, onComplete }) => {
// Create individual sales records for each item
for (const item of data.salesItems) {
const salesData = {
inventory_product_id: item.productId || null, // Include inventory product ID for stock tracking
product_name: item.product,
product_category: 'general', // Could be enhanced with category selection
quantity_sold: item.quantity,

View File

@@ -588,6 +588,14 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
);
};
// Get tour attribute for navigation item
const getTourAttribute = (path: string): string | undefined => {
if (path === '/app/database') return 'sidebar-database';
if (path === '/app/operations') return 'sidebar-operations';
if (path === '/app/analytics') return 'sidebar-analytics';
return undefined;
};
// Render navigation item
const renderItem = (item: NavigationItem, level = 0) => {
const isActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/');
@@ -595,6 +603,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const hasChildren = item.children && item.children.length > 0;
const isHovered = hoveredItem === item.id;
const ItemIcon = item.icon;
const tourAttr = getTourAttribute(item.path);
const itemContent = (
<div
@@ -676,6 +685,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
onClick={() => handleItemClick(item)}
disabled={item.disabled}
data-path={item.path}
data-tour={tourAttr}
onMouseEnter={() => {
if (isCollapsed && hasChildren && level === 0 && item.children && item.children.length > 0) {
setHoveredItem(item.id);

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';

View File

@@ -13,26 +13,26 @@ export const getDemoTourSteps = (): DriveStep[] => [
{
element: '[data-tour="dashboard-stats"]',
popover: {
title: 'Tu Panel de Control',
description: 'Todo lo importante en un vistazo: ventas del día, pedidos pendientes, productos vendidos y alertas de stock crítico. Empieza tu día aquí en 30 segundos.',
title: 'Estado de Salud de Tu Panadería',
description: 'Aquí ves el estado general de tu negocio en tiempo real: órdenes pendientes, alertas de stock crítico, reducción de desperdicio y ahorro mensual. Todo lo importante en un vistazo para empezar el día en 30 segundos.',
side: 'bottom',
align: 'start',
},
},
{
element: '[data-tour="real-time-alerts"]',
popover: {
title: 'El Sistema Te Avisa de Todo',
description: 'Olvídate de vigilar el stock constantemente. El sistema te alerta automáticamente de ingredientes bajos, pedidos urgentes, predicciones de demanda y oportunidades de producción. Tu asistente 24/7.',
side: 'top',
align: 'start',
},
},
{
element: '[data-tour="pending-po-approvals"]',
popover: {
title: 'Qué Comprar Hoy (Ya Calculado)',
description: 'Cada mañana el sistema analiza automáticamente tus ventas, pronósticos y stock, y te dice exactamente qué ingredientes comprar. Solo tienes que revisar y aprobar con un clic. Adiós a Excel y cálculos manuales.',
title: '¿Qué Requiere Tu Atención?',
description: 'Aquí aparecen las acciones que necesitan tu aprobación: órdenes de compra generadas automáticamente, lotes de producción sugeridos, y otras decisiones importantes. Revisa, aprueba o modifica con un clic.',
side: 'top',
align: 'start',
},
},
{
element: '[data-tour="real-time-alerts"]',
popover: {
title: 'Lo Que el Sistema Hizo Por Ti',
description: 'El sistema trabaja 24/7 analizando datos, generando pronósticos y creando planes. Aquí ves un resumen de las decisiones automáticas que tomó: órdenes generadas, producción planificada y optimizaciones realizadas.',
side: 'top',
align: 'start',
},
@@ -114,26 +114,26 @@ export const getMobileTourSteps = (): DriveStep[] => [
{
element: '[data-tour="dashboard-stats"]',
popover: {
title: 'Tu Panel de Control',
description: 'Todo lo importante en un vistazo. Empieza tu día aquí en 30 segundos.',
title: 'Estado de Tu Panadería',
description: 'Estado de salud del negocio en tiempo real. Todo lo importante en un vistazo para empezar el día en 30 segundos.',
side: 'bottom',
align: 'start',
},
},
{
element: '[data-tour="real-time-alerts"]',
popover: {
title: 'El Sistema Te Avisa',
description: 'Olvídate de vigilar el stock. Alertas automáticas de todo lo importante. Tu asistente 24/7.',
side: 'top',
align: 'start',
},
},
{
element: '[data-tour="pending-po-approvals"]',
popover: {
title: 'Qué Comprar (Ya Calculado)',
description: 'Cada mañana el sistema calcula qué ingredientes comprar. Solo aprueba con un clic. Adiós Excel.',
title: '¿Qué Requiere Atención?',
description: 'Acciones que necesitan tu aprobación: compras, producción y decisiones. Revisa y aprueba con un clic.',
side: 'top',
align: 'start',
},
},
{
element: '[data-tour="real-time-alerts"]',
popover: {
title: 'Lo Que el Sistema Hizo',
description: 'Resumen de decisiones automáticas: órdenes generadas, producción planificada, optimizaciones. Tu asistente 24/7.',
side: 'top',
align: 'start',
},

View File

@@ -71,8 +71,8 @@ export const useDemoTour = () => {
}, 500);
}, [navigate]);
const startTour = useCallback((fromStep: number = 0) => {
console.log('[useDemoTour] startTour called with fromStep:', fromStep);
const startTour = useCallback((fromStep: number = 0, retryCount: number = 0) => {
console.log('[useDemoTour] startTour called with fromStep:', fromStep, 'retry:', retryCount);
// Check if we're already on the dashboard
const currentPath = window.location.pathname;
@@ -81,7 +81,7 @@ export const useDemoTour = () => {
// Store tour intent in sessionStorage before navigation
sessionStorage.setItem('demo_tour_should_start', 'true');
sessionStorage.setItem('demo_tour_start_step', fromStep.toString());
// Navigate to dashboard
navigate(ROUTES.DASHBOARD);
return;
@@ -90,20 +90,41 @@ export const useDemoTour = () => {
const steps = isMobile ? getMobileTourSteps() : getDemoTourSteps();
console.log('[useDemoTour] Using', isMobile ? 'mobile' : 'desktop', 'steps, total:', steps.length);
// Check if first element exists (only if we're on the dashboard)
// Check if critical tour elements exist (only if we're on the dashboard)
if (currentPath === ROUTES.DASHBOARD) {
const firstElement = steps[0]?.element;
if (firstElement) {
const selector = typeof firstElement === 'string' ? firstElement : String(firstElement);
// Validate critical dashboard elements
const criticalSelectors = [
'[data-tour="demo-banner"]',
'[data-tour="dashboard-stats"]'
];
let missingElement = null;
for (const selector of criticalSelectors) {
const el = document.querySelector(selector);
console.log('[useDemoTour] First element exists:', !!el, 'selector:', selector);
if (!el) {
console.warn('[useDemoTour] First tour element not found in DOM! Delaying tour start...');
// Retry after DOM is ready
setTimeout(() => startTour(fromStep), 500);
missingElement = selector;
break;
}
}
if (missingElement) {
// Retry up to 5 times with exponential backoff
if (retryCount < 5) {
const delay = Math.min(500 * Math.pow(1.5, retryCount), 3000);
console.warn(`[useDemoTour] Critical tour element "${missingElement}" not found! Retrying in ${delay}ms (attempt ${retryCount + 1}/5)...`);
setTimeout(() => startTour(fromStep, retryCount + 1), delay);
return;
} else {
console.error(`[useDemoTour] Failed to find critical element "${missingElement}" after 5 retries. Tour cannot start.`);
// Clear the tour start flag to prevent infinite retry loops
sessionStorage.removeItem('demo_tour_should_start');
sessionStorage.removeItem('demo_tour_start_step');
clearTourStartPending();
return;
}
}
console.log('[useDemoTour] All critical tour elements found, starting tour...');
}
const config = getDriverConfig(handleStepComplete);

View File

@@ -0,0 +1,148 @@
/**
* Address Autocomplete Hook
*
* Provides address search and geocoding functionality with debouncing
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { geocodingApi, AddressResult } from '@/services/api/geocodingApi';
interface UseAddressAutocompleteOptions {
countryCode?: string;
limit?: number;
debounceMs?: number;
minQueryLength?: number;
}
interface UseAddressAutocompleteReturn {
query: string;
setQuery: (query: string) => void;
results: AddressResult[];
isLoading: boolean;
error: string | null;
selectedAddress: AddressResult | null;
selectAddress: (address: AddressResult) => void;
clearSelection: () => void;
clearResults: () => void;
}
export function useAddressAutocomplete(
options: UseAddressAutocompleteOptions = {}
): UseAddressAutocompleteReturn {
const {
countryCode = 'es',
limit = 10,
debounceMs = 500,
minQueryLength = 3
} = options;
const [query, setQuery] = useState('');
const [results, setResults] = useState<AddressResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedAddress, setSelectedAddress] = useState<AddressResult | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const searchAddresses = useCallback(
async (searchQuery: string) => {
// Clear previous results if query is too short
if (searchQuery.trim().length < minQueryLength) {
setResults([]);
setError(null);
return;
}
// Cancel previous request if still pending
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setIsLoading(true);
setError(null);
try {
const searchResults = await geocodingApi.searchAddresses(
searchQuery,
countryCode,
limit
);
setResults(searchResults);
setError(null);
} catch (err: any) {
// Ignore abort errors
if (err.name === 'AbortError' || err.name === 'CanceledError') {
return;
}
console.error('Address search error:', err);
setError(err.message || 'Failed to search addresses');
setResults([]);
} finally {
setIsLoading(false);
}
},
[countryCode, limit, minQueryLength]
);
// Debounced search effect
useEffect(() => {
// Clear previous timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Don't search if query is empty or selected address matches query
if (!query || (selectedAddress && selectedAddress.display_name === query)) {
setResults([]);
return;
}
// Set new timer
debounceTimerRef.current = setTimeout(() => {
searchAddresses(query);
}, debounceMs);
// Cleanup
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [query, debounceMs, searchAddresses, selectedAddress]);
const selectAddress = useCallback((address: AddressResult) => {
setSelectedAddress(address);
setQuery(address.display_name);
setResults([]);
setError(null);
}, []);
const clearSelection = useCallback(() => {
setSelectedAddress(null);
setQuery('');
setResults([]);
setError(null);
}, []);
const clearResults = useCallback(() => {
setResults([]);
setError(null);
}, []);
return {
query,
setQuery,
results,
isLoading,
error,
selectedAddress,
selectAddress,
clearSelection,
clearResults
};
}

View File

@@ -0,0 +1,209 @@
/**
* POI Context React Hook
*
* Custom hook for managing POI context state and operations
*/
import { useState, useEffect, useCallback } from 'react';
import { poiContextApi } from '@/services/api/poiContextApi';
import type {
POIContext,
POIDetectionResponse,
FeatureImportanceResponse,
CompetitorAnalysis
} from '@/types/poi';
export interface UsePOIContextOptions {
tenantId: string;
autoFetch?: boolean;
}
export interface UsePOIContextResult {
poiContext: POIContext | null;
isLoading: boolean;
isRefreshing: boolean;
error: string | null;
isStale: boolean;
needsRefresh: boolean;
featureImportance: FeatureImportanceResponse | null;
competitorAnalysis: CompetitorAnalysis | null;
competitiveInsights: string[];
// Actions
detectPOIs: (latitude: number, longitude: number, forceRefresh?: boolean) => Promise<void>;
refreshPOIs: () => Promise<void>;
fetchContext: () => Promise<void>;
fetchFeatureImportance: () => Promise<void>;
fetchCompetitorAnalysis: () => Promise<void>;
deletePOIContext: () => Promise<void>;
}
export function usePOIContext({ tenantId, autoFetch = true }: UsePOIContextOptions): UsePOIContextResult {
const [poiContext, setPOIContext] = useState<POIContext | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isStale, setIsStale] = useState(false);
const [needsRefresh, setNeedsRefresh] = useState(false);
const [featureImportance, setFeatureImportance] = useState<FeatureImportanceResponse | null>(null);
const [competitorAnalysis, setCompetitorAnalysis] = useState<CompetitorAnalysis | null>(null);
const [competitiveInsights, setCompetitiveInsights] = useState<string[]>([]);
const fetchContext = useCallback(async () => {
if (!tenantId) return;
try {
setIsLoading(true);
setError(null);
const response = await poiContextApi.getPOIContext(tenantId);
setPOIContext(response.poi_context);
setIsStale(response.is_stale);
setNeedsRefresh(response.needs_refresh);
} catch (err: any) {
if (err.response?.status === 404) {
// No POI context found - this is normal for new tenants
setPOIContext(null);
setError(null);
} else {
setError(err.message || 'Failed to fetch POI context');
console.error('Error fetching POI context:', err);
}
} finally {
setIsLoading(false);
}
}, [tenantId]);
const detectPOIs = useCallback(async (
latitude: number,
longitude: number,
forceRefresh: boolean = false
) => {
if (!tenantId) return;
try {
setIsLoading(true);
setError(null);
const response = await poiContextApi.detectPOIs(
tenantId,
latitude,
longitude,
forceRefresh
);
setPOIContext(response.poi_context);
setIsStale(false);
setNeedsRefresh(false);
// Update competitor analysis if available
if (response.competitor_analysis) {
setCompetitorAnalysis(response.competitor_analysis);
}
// Update competitive insights if available
if (response.competitive_insights) {
setCompetitiveInsights(response.competitive_insights);
}
} catch (err: any) {
setError(err.message || 'Failed to detect POIs');
console.error('Error detecting POIs:', err);
} finally {
setIsLoading(false);
}
}, [tenantId]);
const refreshPOIs = useCallback(async () => {
if (!tenantId) return;
try {
setIsRefreshing(true);
setError(null);
const response = await poiContextApi.refreshPOIContext(tenantId);
setPOIContext(response.poi_context);
setIsStale(false);
setNeedsRefresh(false);
// Update competitor analysis if available
if (response.competitor_analysis) {
setCompetitorAnalysis(response.competitor_analysis);
}
// Update competitive insights if available
if (response.competitive_insights) {
setCompetitiveInsights(response.competitive_insights);
}
} catch (err: any) {
setError(err.message || 'Failed to refresh POI context');
console.error('Error refreshing POI context:', err);
} finally {
setIsRefreshing(false);
}
}, [tenantId]);
const fetchFeatureImportance = useCallback(async () => {
if (!tenantId) return;
try {
const response = await poiContextApi.getFeatureImportance(tenantId);
setFeatureImportance(response);
} catch (err: any) {
console.error('Error fetching feature importance:', err);
}
}, [tenantId]);
const fetchCompetitorAnalysis = useCallback(async () => {
if (!tenantId) return;
try {
const response = await poiContextApi.getCompetitorAnalysis(tenantId);
setCompetitorAnalysis(response.competitor_analysis);
setCompetitiveInsights(response.insights);
} catch (err: any) {
console.error('Error fetching competitor analysis:', err);
}
}, [tenantId]);
const deletePOIContext = useCallback(async () => {
if (!tenantId) return;
try {
await poiContextApi.deletePOIContext(tenantId);
setPOIContext(null);
setFeatureImportance(null);
setCompetitorAnalysis(null);
setCompetitiveInsights([]);
setIsStale(false);
setNeedsRefresh(false);
} catch (err: any) {
setError(err.message || 'Failed to delete POI context');
console.error('Error deleting POI context:', err);
}
}, [tenantId]);
// Auto-fetch on mount if enabled
useEffect(() => {
if (autoFetch && tenantId) {
fetchContext();
}
}, [autoFetch, tenantId, fetchContext]);
return {
poiContext,
isLoading,
isRefreshing,
error,
isStale,
needsRefresh,
featureImportance,
competitorAnalysis,
competitiveInsights,
detectPOIs,
refreshPOIs,
fetchContext,
fetchFeatureImportance,
fetchCompetitorAnalysis,
deletePOIContext
};
}

View File

@@ -1,4 +1,12 @@
{
"toc": {
"automatic": "Automatic System",
"local": "Local Intelligence",
"forecasting": "Demand Forecasting",
"waste": "Waste Reduction",
"sustainability": "Sustainability",
"business": "Business Models"
},
"hero": {
"title": "How Bakery-AI Works For You Every Day",
"subtitle": "All features explained in simple language for bakery owners"

View File

@@ -1,16 +1,28 @@
{
"hero": {
"pre_headline": "For Bakeries Losing €500-2,000/Month on Waste",
"scarcity": "Only 12 spots left out of 20 • 3 months FREE",
"scarcity_badge": "🔥 Only 12 spots left out of 20 in pilot program",
"badge": "Advanced AI for Modern Bakeries",
"title_line1": "Stop Losing €2,000 Per Month",
"title_line2": "on Bread Nobody Buys",
"subtitle": "AI that predicts exactly what you'll sell tomorrow. Produce just enough. Reduce waste. Increase profits. <strong>3 months free for the first 20 bakeries</strong>.",
"cta_primary": "Request Pilot Spot",
"title_line1": "Increase Profits,",
"title_line2": "Reduce Waste",
"title_option_a_line1": "Save €500-2,000 Per Month",
"title_option_a_line2": "By Producing Exactly What You'll Sell",
"title_option_b": "Stop Guessing How Much to Bake Every Day",
"subtitle": "AI that predicts demand using local data so you produce exactly what you'll sell. Reduce waste, improve margins, save time.",
"subtitle_option_a": "The first AI that knows your neighborhood: nearby schools, local weather, your competition, events. Automatic system every morning. Ready at 6 AM.",
"subtitle_option_b": "AI that knows your area predicts sales with 92% accuracy. Wake up with your plan ready: what to make, what to order, when it arrives. Save €500-2,000/month on waste.",
"cta_primary": "Join Pilot Program",
"cta_secondary": "See How It Works (2 min)",
"social_proof": {
"bakeries": "20 bakeries already saving €1,500/month on average",
"accuracy": "92% accurate predictions (vs 60% generic systems)",
"setup": "15-minute setup"
},
"trust": {
"no_cc": "3 months free",
"card": "Card required",
"quick": "Ready in 10 minutes",
"quick": "15-minute setup",
"spanish": "Support in Spanish"
}
},

View File

@@ -7,6 +7,10 @@
"title": "Register Bakery",
"description": "Configure your bakery's basic information"
},
"poi_detection": {
"title": "Location Analysis",
"description": "Detect nearby points of interest"
},
"smart_inventory_setup": {
"title": "Configure Inventory",
"description": "Upload sales data and set up your initial inventory"
@@ -190,5 +194,22 @@
"invalid_url": "Invalid URL",
"file_too_large": "File too large",
"invalid_file_type": "Invalid file type"
},
"stock": {
"title": "Initial Stock Levels",
"subtitle": "Enter current quantities for each product. This allows the system to track inventory from today.",
"info_title": "Why is this important?",
"info_text": "Without initial stock levels, the system cannot alert you about low stock, plan production, or calculate costs correctly. Take a moment to enter your current quantities.",
"progress": "Capture progress",
"set_all_zero": "Set all to 0",
"skip_for_now": "Skip for now (will be set to 0)",
"ingredients": "Ingredients",
"finished_products": "Finished Products",
"incomplete_warning": "{{count}} products remaining",
"incomplete_help": "You can continue, but we recommend entering all quantities for better inventory control.",
"complete": "Complete Setup",
"continue_anyway": "Continue anyway",
"no_products_title": "Initial Stock",
"no_products_message": "You can configure stock levels later in the inventory section."
}
}

View File

@@ -1,4 +1,12 @@
{
"toc": {
"automatic": "Sistema Automático",
"local": "Inteligencia Local",
"forecasting": "Predicción de Demanda",
"waste": "Reducción de Desperdicios",
"sustainability": "Sostenibilidad",
"business": "Modelos de Negocio"
},
"hero": {
"title": "Cómo Bakery-IA Trabaja Para Ti Cada Día",
"subtitle": "Todas las funcionalidades explicadas en lenguaje sencillo para dueños de panaderías"

View File

@@ -1,16 +1,28 @@
{
"hero": {
"pre_headline": "Para Panaderías que Pierden €500-2,000/Mes en Desperdicios",
"scarcity": "Solo 12 plazas restantes de 20 • 3 meses GRATIS",
"scarcity_badge": "🔥 Solo 12 plazas restantes de 20 en el programa piloto",
"badge": "IA Avanzada para Panaderías Modernas",
"title_line1": "Deja de Perder €2,000 al Mes",
"title_line2": "en Pan Que Nadie Compra",
"subtitle": "IA que predice exactamente cuánto venderás mañana. Produce lo justo. Reduce desperdicios. Aumenta ganancias. <strong>3 meses gratis para las primeras 20 panaderías</strong>.",
"cta_primary": "Solicitar Plaza en el Piloto",
"title_line1": "Aumenta Ganancias,",
"title_line2": "Reduce Desperdicios",
"title_option_a_line1": "Ahorra €500-2,000 al Mes",
"title_option_a_line2": "Produciendo Exactamente Lo Que Venderás",
"title_option_b": "Deja de Adivinar Cuánto Hornear Cada Día",
"subtitle": "IA que predice demanda con datos de tu zona para que produzcas exactamente lo que vas a vender. Reduce desperdicios, mejora márgenes y ahorra tiempo.",
"subtitle_option_a": "La primera IA que conoce tu barrio: colegios cerca, clima local, tu competencia, eventos. Sistema automático cada mañana. Listo a las 6 AM.",
"subtitle_option_b": "IA que conoce tu zona predice ventas con 92% de precisión. Despierta con tu plan listo: qué hacer, qué pedir, cuándo llegará. Ahorra €500-2,000/mes en desperdicios.",
"cta_primary": "Únete al Programa Piloto",
"cta_secondary": "Ver Cómo Funciona (2 min)",
"social_proof": {
"bakeries": "20 panaderías ya ahorran €1,500/mes de promedio",
"accuracy": "Predicciones 92% precisas (vs 60% sistemas genéricos)",
"setup": "Configuración en 15 minutos"
},
"trust": {
"no_cc": "3 meses gratis",
"card": "Tarjeta requerida",
"quick": "Lista en 10 minutos",
"quick": "Configuración en 15 min",
"spanish": "Soporte en español"
}
},

View File

@@ -16,6 +16,10 @@
"title": "Registrar Panadería",
"description": "Información básica"
},
"poi_detection": {
"title": "Análisis de Ubicación",
"description": "Detectar puntos de interés cercanos"
},
"smart_inventory": {
"title": "Subir Datos de Ventas",
"description": "Configuración con IA"
@@ -394,6 +398,8 @@
"incomplete_warning": "Faltan {{count}} productos por completar",
"incomplete_help": "Puedes continuar, pero recomendamos ingresar todas las cantidades para un mejor control de inventario.",
"complete": "Completar Configuración",
"continue_anyway": "Continuar de todos modos"
"continue_anyway": "Continuar de todos modos",
"no_products_title": "Stock Inicial",
"no_products_message": "Podrás configurar los niveles de stock más tarde en la sección de inventario."
}
}

View File

@@ -15,7 +15,7 @@
* - Trust-building (explain system reasoning)
*/
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { RefreshCw, ExternalLink, Plus, Sparkles } from 'lucide-react';
import { useTenant } from '../../stores/tenant.store';
@@ -29,6 +29,7 @@ import {
useStartProductionBatch,
usePauseProductionBatch,
} from '../../api/hooks/newDashboard';
import { useRejectPurchaseOrder } from '../../api/hooks/purchase-orders';
import { HealthStatusCard } from '../../components/dashboard/HealthStatusCard';
import { ActionQueueCard } from '../../components/dashboard/ActionQueueCard';
import { OrchestrationSummaryCard } from '../../components/dashboard/OrchestrationSummaryCard';
@@ -36,11 +37,15 @@ import { ProductionTimelineCard } from '../../components/dashboard/ProductionTim
import { InsightsGrid } from '../../components/dashboard/InsightsGrid';
import { UnifiedAddWizard } from '../../components/domain/unified-wizard';
import type { ItemType } from '../../components/domain/unified-wizard';
import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../features/demo-onboarding';
import { DemoBanner } from '../../components/layout/DemoBanner/DemoBanner';
export function NewDashboardPage() {
const navigate = useNavigate();
const { currentTenant } = useTenant();
const tenantId = currentTenant?.id || '';
const { startTour } = useDemoTour();
const isDemoMode = localStorage.getItem('demo_mode') === 'true';
// Unified Add Wizard state
const [isAddWizardOpen, setIsAddWizardOpen] = useState(false);
@@ -79,6 +84,7 @@ export function NewDashboardPage() {
// Mutations
const approvePO = useApprovePurchaseOrder();
const rejectPO = useRejectPurchaseOrder();
const startBatch = useStartProductionBatch();
const pauseBatch = usePauseProductionBatch();
@@ -94,6 +100,17 @@ export function NewDashboardPage() {
}
};
const handleReject = async (actionId: string, reason: string) => {
try {
await rejectPO.mutateAsync({ tenantId, poId: actionId, reason });
// Refetch to update UI
refetchActionQueue();
refetchHealth();
} catch (error) {
console.error('Error rejecting PO:', error);
}
};
const handleViewDetails = (actionId: string) => {
// Navigate to appropriate detail page based on action type
navigate(`/app/operations/procurement`);
@@ -137,8 +154,43 @@ export function NewDashboardPage() {
handleRefreshAll();
};
// Demo tour auto-start logic
useEffect(() => {
console.log('[Dashboard] Demo mode:', isDemoMode);
console.log('[Dashboard] Should start tour:', shouldStartTour());
console.log('[Dashboard] SessionStorage demo_tour_should_start:', sessionStorage.getItem('demo_tour_should_start'));
console.log('[Dashboard] SessionStorage demo_tour_start_step:', sessionStorage.getItem('demo_tour_start_step'));
// Check if there's a tour intent from redirection (higher priority)
const shouldStartFromRedirect = sessionStorage.getItem('demo_tour_should_start') === 'true';
const redirectStartStep = parseInt(sessionStorage.getItem('demo_tour_start_step') || '0', 10);
if (isDemoMode && (shouldStartTour() || shouldStartFromRedirect)) {
console.log('[Dashboard] Starting tour in 1.5s...');
const timer = setTimeout(() => {
console.log('[Dashboard] Executing startTour()');
if (shouldStartFromRedirect) {
// Start tour from the specific step that was intended
startTour(redirectStartStep);
// Clear the redirect intent
sessionStorage.removeItem('demo_tour_should_start');
sessionStorage.removeItem('demo_tour_start_step');
} else {
// Start tour normally (from beginning or resume)
startTour();
clearTourStartPending();
}
}, 1500);
return () => clearTimeout(timer);
}
}, [isDemoMode, startTour]);
return (
<div className="min-h-screen pb-20 md:pb-8" style={{ backgroundColor: 'var(--bg-secondary)' }}>
{/* Demo Banner */}
{isDemoMode && <DemoBanner />}
{/* Mobile-optimized container */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Header */}
@@ -183,30 +235,40 @@ export function NewDashboardPage() {
{/* Main Dashboard Layout */}
<div className="space-y-6">
{/* SECTION 1: Bakery Health Status */}
<HealthStatusCard healthStatus={healthStatus} loading={healthLoading} />
<div data-tour="dashboard-stats">
<HealthStatusCard healthStatus={healthStatus} loading={healthLoading} />
</div>
{/* SECTION 2: What Needs Your Attention (Action Queue) */}
<ActionQueueCard
actionQueue={actionQueue}
loading={actionQueueLoading}
onApprove={handleApprove}
onViewDetails={handleViewDetails}
onModify={handleModify}
/>
<div data-tour="pending-po-approvals">
<ActionQueueCard
actionQueue={actionQueue}
loading={actionQueueLoading}
onApprove={handleApprove}
onReject={handleReject}
onViewDetails={handleViewDetails}
onModify={handleModify}
tenantId={tenantId}
/>
</div>
{/* SECTION 3: What the System Did for You (Orchestration Summary) */}
<OrchestrationSummaryCard
summary={orchestrationSummary}
loading={orchestrationLoading}
/>
<div data-tour="real-time-alerts">
<OrchestrationSummaryCard
summary={orchestrationSummary}
loading={orchestrationLoading}
/>
</div>
{/* SECTION 4: Today's Production Timeline */}
<ProductionTimelineCard
timeline={productionTimeline}
loading={timelineLoading}
onStart={handleStartBatch}
onPause={handlePauseBatch}
/>
<div data-tour="today-production">
<ProductionTimelineCard
timeline={productionTimeline}
loading={timelineLoading}
onStart={handleStartBatch}
onPause={handlePauseBatch}
/>
</div>
{/* SECTION 5: Quick Insights Grid */}
<div>

View File

@@ -28,6 +28,75 @@ const SustainabilityPage: React.FC = () => {
// Date range state (default to last 30 days)
const [dateRange, setDateRange] = useState<{ start?: string; end?: string }>({});
// CSV Export function
const exportToCSV = () => {
if (!metrics) return;
const timestamp = new Date().toISOString().split('T')[0];
const filename = `sustainability_report_${currentTenant?.name || 'bakery'}_${timestamp}.csv`;
// Build CSV content
const csvRows = [
['Sustainability Report'],
['Generated:', new Date().toLocaleString()],
['Bakery:', currentTenant?.name || 'N/A'],
['Period:', `${metrics.period.start_date} to ${metrics.period.end_date}`],
[],
['WASTE METRICS'],
['Total Waste (kg)', metrics.waste_metrics.total_waste_kg.toFixed(2)],
['Production Waste (kg)', metrics.waste_metrics.production_waste_kg.toFixed(2)],
['Inventory Waste (kg)', metrics.waste_metrics.inventory_waste_kg.toFixed(2)],
['Waste Percentage (%)', metrics.waste_metrics.waste_percentage.toFixed(2)],
[],
['SDG 12.3 COMPLIANCE'],
['Status', metrics.sdg_compliance.sdg_12_3.status_label],
['Reduction Achieved (%)', metrics.sdg_compliance.sdg_12_3.reduction_achieved.toFixed(2)],
['Progress to Target (%)', metrics.sdg_compliance.sdg_12_3.progress_to_target.toFixed(2)],
['Target (%)', metrics.sdg_compliance.sdg_12_3.target_percentage],
[],
['ENVIRONMENTAL IMPACT'],
['CO2 Emissions (kg)', metrics.environmental_impact.co2_emissions.kg.toFixed(2)],
['CO2 Emissions (tons)', metrics.environmental_impact.co2_emissions.tons.toFixed(4)],
['Trees to Offset', metrics.environmental_impact.co2_emissions.trees_to_offset.toFixed(1)],
['Equivalent Car KM', metrics.environmental_impact.co2_emissions.car_km_equivalent.toFixed(0)],
['Water Footprint (liters)', metrics.environmental_impact.water_footprint.liters.toFixed(0)],
['Water Footprint (m³)', metrics.environmental_impact.water_footprint.cubic_meters.toFixed(2)],
['Equivalent Showers', metrics.environmental_impact.water_footprint.shower_equivalent.toFixed(0)],
['Land Use (m²)', metrics.environmental_impact.land_use.square_meters.toFixed(2)],
['Land Use (hectares)', metrics.environmental_impact.land_use.hectares.toFixed(4)],
[],
['FINANCIAL IMPACT'],
['Waste Cost (EUR)', metrics.financial_impact.waste_cost_eur.toFixed(2)],
['Potential Monthly Savings (EUR)', metrics.financial_impact.potential_monthly_savings.toFixed(2)],
['ROI on Prevention (%)', metrics.financial_impact.roi_on_waste_prevention.toFixed(2)],
[],
['AVOIDED WASTE (AI PREDICTIONS)'],
['Waste Avoided (kg)', metrics.avoided_waste.total_waste_avoided_kg.toFixed(2)],
['Cost Savings (EUR)', metrics.avoided_waste.cost_savings_eur.toFixed(2)],
['CO2 Avoided (kg)', metrics.avoided_waste.environmental_impact_avoided.co2_kg.toFixed(2)],
['Water Saved (liters)', metrics.avoided_waste.environmental_impact_avoided.water_liters.toFixed(0)],
[],
['GRANT READINESS'],
['Certification Ready', metrics.sdg_compliance.certification_ready ? 'Yes' : 'No'],
['Eligible Programs', Object.values(metrics.grant_readiness.grant_programs).filter(p => p.eligible).length],
['Recommended Applications', metrics.grant_readiness.recommended_applications.join(', ')]
];
// Convert to CSV string
const csvContent = csvRows.map(row => row.join(',')).join('\n');
// Create download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// Fetch sustainability metrics
const {
data: metrics,
@@ -226,12 +295,10 @@ const SustainabilityPage: React.FC = () => {
id: "export-report",
label: t('sustainability:actions.export_report', 'Exportar Informe'),
icon: Download,
onClick: () => {
// TODO: Implement export
console.log('Export sustainability report');
},
onClick: exportToCSV,
variant: "outline",
size: "sm"
size: "sm",
disabled: !metrics
}
]}
/>

View File

@@ -17,9 +17,9 @@ import type { ItemType } from '../../../../components/domain/unified-wizard';
// Import AddStockModal separately since we need it for adding batches
import AddStockModal from '../../../../components/domain/inventory/AddStockModal';
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useUpdateStock, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useUpdateStock, useCreateStockMovement, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
import { useTenantId } from '../../../../hooks/useTenantId';
import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory';
import { IngredientResponse, StockCreate, StockMovementCreate, StockMovementType, IngredientCreate } from '../../../../api/types/inventory';
import { subscriptionService } from '../../../../api/services/subscription';
import { useQueryClient } from '@tanstack/react-query';
@@ -55,6 +55,7 @@ const InventoryPage: React.FC = () => {
const consumeStockMutation = useConsumeStock();
const updateIngredientMutation = useUpdateIngredient();
const updateStockMutation = useUpdateStock();
const createStockMovementMutation = useCreateStockMovement();
// API Data
const {
@@ -795,8 +796,36 @@ const InventoryPage: React.FC = () => {
});
}}
onMarkAsWaste={async (batchId) => {
// TODO: Implement mark as waste functionality
console.log('Mark as waste:', batchId);
if (!tenantId || !batches) return;
try {
// Find the batch to get its details
const batch = batches.find(b => b.id === batchId);
if (!batch) {
console.error('Batch not found:', batchId);
return;
}
// Create a waste movement for the entire current quantity
const movementData: StockMovementCreate = {
ingredient_id: selectedItem!.id,
stock_id: batchId,
movement_type: StockMovementType.WASTE,
quantity: batch.current_quantity,
unit_cost: batch.unit_cost || undefined,
notes: `Batch marked as waste. Reason: ${batch.is_expired ? 'Expired' : 'Damaged/Spoiled'}`
};
await createStockMovementMutation.mutateAsync({
tenantId,
movementData
});
// Refresh the batches list
queryClient.invalidateQueries({ queryKey: ['stock', 'byIngredient', tenantId, selectedItem!.id] });
} catch (error) {
console.error('Failed to mark batch as waste:', error);
}
}}
waitForRefetch={true}
isRefetching={isRefetchingBatches}

View File

@@ -21,7 +21,7 @@ import {
CustomerType,
CustomerSegment
} from '../../../../api/types/orders';
import { useOrders, useCustomers, useOrdersDashboard, useCreateOrder, useCreateCustomer, useUpdateCustomer } from '../../../../api/hooks/orders';
import { useOrders, useCustomers, useOrdersDashboard, useCreateOrder, useUpdateOrder, useCreateCustomer, useUpdateCustomer } from '../../../../api/hooks/orders';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAuthUser } from '../../../../stores/auth.store';
import { OrderFormModal } from '../../../../components/domain/orders';
@@ -76,6 +76,7 @@ const OrdersPage: React.FC = () => {
// Mutations
const createOrderMutation = useCreateOrder();
const updateOrderMutation = useUpdateOrder();
const createCustomerMutation = useCreateCustomer();
const updateCustomerMutation = useUpdateCustomer();
@@ -634,11 +635,36 @@ const OrdersPage: React.FC = () => {
sections={sections}
showDefaultActions={true}
onSave={async () => {
// TODO: Implement order update functionality
// Note: The backend only has updateOrderStatus, not a general update endpoint
// For now, orders can be updated via status changes using useUpdateOrderStatus
console.log('Saving order:', selectedOrder);
console.warn('Order update not yet implemented - only status updates are supported via useUpdateOrderStatus');
if (!selectedOrder || !tenantId) return;
try {
// Build the update payload from the selectedOrder state
const updateData = {
status: selectedOrder.status,
priority: selectedOrder.priority,
requested_delivery_date: selectedOrder.requested_delivery_date,
delivery_method: selectedOrder.delivery_method,
delivery_address: selectedOrder.delivery_address,
delivery_instructions: selectedOrder.delivery_instructions,
delivery_window_start: selectedOrder.delivery_window_start,
delivery_window_end: selectedOrder.delivery_window_end,
payment_method: selectedOrder.payment_method,
payment_status: selectedOrder.payment_status,
special_instructions: selectedOrder.special_instructions,
custom_requirements: selectedOrder.custom_requirements,
allergen_warnings: selectedOrder.allergen_warnings,
};
await updateOrderMutation.mutateAsync({
tenantId: tenantId,
orderId: selectedOrder.id,
data: updateData
});
setShowForm(false);
} catch (error) {
console.error('Failed to update order:', error);
}
}}
onFieldChange={(sectionIndex, fieldIndex, value) => {
const newOrder = { ...selectedOrder };

View File

@@ -11,6 +11,7 @@ import { showToast } from '../../../../utils/toast';
import { usePOSConfigurationData, usePOSConfigurationManager, usePOSTransactions, usePOSTransactionsDashboard, usePOSTransaction } from '../../../../api/hooks/pos';
import { POSConfiguration } from '../../../../api/types/pos';
import { posService } from '../../../../api/services/pos';
import { salesService } from '../../../../api/services/sales';
import { bakeryColors } from '../../../../styles/colors';
// Import new POS components
@@ -18,6 +19,7 @@ import { POSProductCard } from '../../../../components/domain/pos/POSProductCard
import { POSCart } from '../../../../components/domain/pos/POSCart';
import { POSPayment } from '../../../../components/domain/pos/POSPayment';
import { CreatePOSConfigModal } from '../../../../components/domain/pos/CreatePOSConfigModal';
import { POSSyncStatus } from '../../../../components/domain/pos/POSSyncStatus';
interface CartItem {
id: string;
@@ -752,17 +754,37 @@ const POSPage: React.FC = () => {
const tax = subtotal * taxRate;
const total = subtotal + tax;
const processPayment = (paymentData: any) => {
const processPayment = async (paymentData: any) => {
if (cart.length === 0) return;
console.log('Processing payment:', {
cart,
...paymentData,
total,
});
try {
// Create sales records for each item in the cart
const saleDate = new Date().toISOString().split('T')[0];
setCart([]);
showToast.success('Venta procesada exitosamente');
for (const item of cart) {
const salesData = {
inventory_product_id: item.id, // Product ID for inventory tracking
product_name: item.name,
product_category: 'finished_product',
quantity_sold: item.quantity,
unit_price: item.price,
total_amount: item.price * item.quantity,
sale_date: saleDate,
sales_channel: 'pos',
source: 'manual_pos',
payment_method: paymentData.method || 'cash',
notes: paymentData.notes || 'Venta desde POS manual',
};
await salesService.createSalesRecord(tenantId, salesData);
}
setCart([]);
showToast.success(`Venta procesada exitosamente: €${total.toFixed(2)}`);
} catch (error: any) {
console.error('Error processing payment:', error);
showToast.error(error.response?.data?.detail || 'Error al procesar la venta');
}
};
// Loading and error states
@@ -1030,6 +1052,11 @@ const POSPage: React.FC = () => {
{posData.configurations.length > 0 && (
<TransactionsSection tenantId={tenantId} />
)}
{/* POS to Sales Sync Status - Only show if there are configurations */}
{posData.configurations.length > 0 && (
<POSSyncStatus tenantId={tenantId} />
)}
</div>
)}

View File

@@ -135,78 +135,19 @@ const ProductionPage: React.FC = () => {
// The QualityCheckModal should be enhanced to handle stage-specific checks
};
// Helper function to generate mock process stage data for the selected batch
const generateMockProcessStageData = (batch: ProductionBatchResponse) => {
// Mock data based on batch status - this would come from the API in real implementation
const mockProcessStage = {
current: batch.status === ProductionStatusEnum.PENDING ? 'mixing' as const :
batch.status === ProductionStatusEnum.IN_PROGRESS ? 'baking' as const :
batch.status === ProductionStatusEnum.QUALITY_CHECK ? 'cooling' as const :
'finishing' as const,
history: batch.status !== ProductionStatusEnum.PENDING ? [
{ stage: 'mixing' as const, timestamp: batch.actual_start_time || batch.planned_start_time, duration: 30 },
...(batch.status === ProductionStatusEnum.IN_PROGRESS || batch.status === ProductionStatusEnum.QUALITY_CHECK || batch.status === ProductionStatusEnum.COMPLETED ? [
{ stage: 'proofing' as const, timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), duration: 90 },
{ stage: 'shaping' as const, timestamp: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(), duration: 15 }
] : []),
...(batch.status === ProductionStatusEnum.QUALITY_CHECK || batch.status === ProductionStatusEnum.COMPLETED ? [
{ stage: 'baking' as const, timestamp: new Date(Date.now() - 30 * 60 * 1000).toISOString(), duration: 45 }
] : [])
] : [],
pendingQualityChecks: batch.status === ProductionStatusEnum.IN_PROGRESS ? [
{
id: 'qc1',
name: 'Control de temperatura interna',
stage: 'baking' as const,
isRequired: true,
isCritical: true,
status: 'pending' as const,
checkType: 'temperature' as const
}
] : batch.status === ProductionStatusEnum.QUALITY_CHECK ? [
{
id: 'qc2',
name: 'Inspección visual final',
stage: 'cooling' as const,
isRequired: true,
isCritical: false,
status: 'pending' as const,
checkType: 'visual' as const
}
] : [],
completedQualityChecks: batch.status === ProductionStatusEnum.COMPLETED ? [
{
id: 'qc1',
name: 'Control de temperatura interna',
stage: 'baking' as const,
isRequired: true,
isCritical: true,
status: 'completed' as const,
checkType: 'temperature' as const
},
{
id: 'qc2',
name: 'Inspección visual final',
stage: 'cooling' as const,
isRequired: true,
isCritical: false,
status: 'completed' as const,
checkType: 'visual' as const
}
] : batch.status === ProductionStatusEnum.IN_PROGRESS ? [
{
id: 'qc3',
name: 'Verificación de masa',
stage: 'mixing' as const,
isRequired: true,
isCritical: false,
status: 'completed' as const,
checkType: 'visual' as const
}
] : []
// Helper function to get process stage data from the batch (now from real backend data)
const getProcessStageData = (batch: ProductionBatchResponse) => {
// Backend now provides these fields in the API response:
// - current_process_stage
// - process_stage_history
// - pending_quality_checks
// - completed_quality_checks
return {
current: batch.current_process_stage || 'mixing',
history: batch.process_stage_history || [],
pendingQualityChecks: batch.pending_quality_checks || [],
completedQualityChecks: batch.completed_quality_checks || []
};
return mockProcessStage;
};
@@ -576,7 +517,7 @@ const ProductionPage: React.FC = () => {
label: '',
value: (
<CompactProcessStageTracker
processStage={generateMockProcessStageData(selectedBatch)}
processStage={getProcessStageData(selectedBatch)}
onAdvanceStage={(currentStage) => handleStageAdvance(selectedBatch.id, currentStage)}
onQualityCheck={(checkId) => {
setShowQualityModal(true);

View File

@@ -1,8 +1,19 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PublicLayout } from '../../components/layout';
import { Button } from '../../components/ui';
import {
Button,
TableOfContents,
ProgressBar,
FloatingCTA,
ScrollReveal,
SavingsCalculator,
StepTimeline,
AnimatedCounter,
TOCSection,
TimelineStep
} from '../../components/ui';
import { getDemoUrl } from '../../utils/navigation';
import {
Clock,
@@ -35,6 +46,110 @@ import {
const FeaturesPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
// Automatic System Timeline Steps
const automaticSystemSteps: TimelineStep[] = [
{
id: 'step1',
number: 1,
title: t('features:automatic.step1.title', 'Revisa Todo Tu Inventario'),
color: 'blue',
items: [
t('features:automatic.step1.item1', 'Cuenta cada kilo de harina, cada litro de leche'),
t('features:automatic.step1.item2', 'Comprueba fechas de caducidad'),
t('features:automatic.step1.item3', 'Ve qué llega hoy de proveedores'),
],
},
{
id: 'step2',
number: 2,
title: t('features:automatic.step2.title', 'Predice Ventas de Hoy'),
color: 'purple',
items: [
t('features:automatic.step2.item1', 'Analiza el día (lunes lluvioso, fiesta local, colegio cerrado)'),
t('features:automatic.step2.item2', 'Compara con días similares del pasado'),
t('features:automatic.step2.item3', 'Te dice: "Hoy venderás 80 croissants, 120 barras, 50 magdalenas"'),
],
},
{
id: 'step3',
number: 3,
title: t('features:automatic.step3.title', 'Planifica Qué Hacer'),
color: 'green',
items: [
t('features:automatic.step3.item1', 'Calcula exactamente cuánto hornear'),
t('features:automatic.step3.item2', 'Te da una lista lista para ejecutar'),
t('features:automatic.step3.item3', '"Haz 80 croissants (no 100), usa 5kg mantequilla, 3kg harina..."'),
],
},
{
id: 'step4',
number: 4,
title: t('features:automatic.step4.title', 'Gestiona Inventario Inteligentemente'),
color: 'amber',
items: [
t('features:automatic.step4.projection_title', 'Proyecta 7 días hacia adelante:'),
t('features:automatic.step4.solution', '"Pide 50kg hoy, llega en 3 días, problema resuelto"'),
],
},
{
id: 'step5',
number: 5,
title: t('features:automatic.step5.title', 'Crea Pedidos a Proveedores'),
color: 'red',
items: [
t('features:automatic.step5.item1', 'Sabe que Proveedor A tarda 3 días, Proveedor B tarda 5'),
t('features:automatic.step5.item2', 'Calcula cuándo pedir para que llegue justo a tiempo'),
t('features:automatic.step5.item3', 'Prepara pedidos listos para aprobar con 1 clic'),
],
},
{
id: 'step6',
number: 6,
title: t('features:automatic.step6.title', 'Previene Desperdicios'),
color: 'teal',
items: [
t('features:automatic.step6.item1', '"Tienes leche que caduca en 5 días"'),
t('features:automatic.step6.item2', '"Solo usarás 15L en 5 días"'),
t('features:automatic.step6.item3', '"No pidas más de 15L, se desperdiciará"'),
],
},
];
// Table of Contents sections
const tocSections: TOCSection[] = [
{
id: 'automatic-system',
label: t('features:toc.automatic', 'Sistema Automático'),
icon: <Clock className="w-4 h-4" />
},
{
id: 'local-intelligence',
label: t('features:toc.local', 'Inteligencia Local'),
icon: <MapPin className="w-4 h-4" />
},
{
id: 'demand-forecasting',
label: t('features:toc.forecasting', 'Predicción de Demanda'),
icon: <Target className="w-4 h-4" />
},
{
id: 'waste-reduction',
label: t('features:toc.waste', 'Reducción de Desperdicios'),
icon: <Recycle className="w-4 h-4" />
},
{
id: 'sustainability',
label: t('features:toc.sustainability', 'Sostenibilidad'),
icon: <Leaf className="w-4 h-4" />
},
{
id: 'business-models',
label: t('features:toc.business', 'Modelos de Negocio'),
icon: <Store className="w-4 h-4" />
},
];
return (
<PublicLayout
@@ -47,226 +162,71 @@ const FeaturesPage: React.FC = () => {
variant: "default"
}}
>
{/* Progress Bar */}
<ProgressBar position="top" height={3} />
{/* Floating CTA */}
<FloatingCTA
text={t('features:cta.button', 'Solicitar Demo')}
onClick={() => navigate(getDemoUrl())}
icon={<ArrowRight className="w-4 h-4" />}
position="bottom-right"
showAfterScroll={500}
dismissible
/>
{/* Main Content with Sidebar */}
<div className="flex gap-8">
{/* Sidebar - Table of Contents */}
<aside className="hidden lg:block w-64 flex-shrink-0">
<TableOfContents sections={tocSections} />
</aside>
{/* Main Content */}
<div className="flex-1 min-w-0">
{/* Hero Section */}
<section className="bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5 py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center max-w-4xl mx-auto">
<h1 className="text-4xl lg:text-6xl font-extrabold text-[var(--text-primary)] mb-6">
{t('features:hero.title', 'Cómo Bakery-IA Trabaja Para Ti Cada Día')}
</h1>
<p className="text-xl text-[var(--text-secondary)] leading-relaxed">
{t('features:hero.subtitle', 'Todas las funcionalidades explicadas en lenguaje sencillo para dueños de panaderías')}
</p>
</div>
<ScrollReveal variant="fadeUp" delay={0.1}>
<div className="text-center max-w-4xl mx-auto">
<h1 className="text-4xl lg:text-6xl font-extrabold text-[var(--text-primary)] mb-6">
{t('features:hero.title', 'Cómo Bakery-IA Trabaja Para Ti Cada Día')}
</h1>
<p className="text-xl text-[var(--text-secondary)] leading-relaxed">
{t('features:hero.subtitle', 'Todas las funcionalidades explicadas en lenguaje sencillo para dueños de panaderías')}
</p>
</div>
</ScrollReveal>
</div>
</section>
{/* Feature 1: Automatic Daily System - THE KILLER FEATURE */}
<section className="py-20 bg-[var(--bg-primary)]">
<section id="automatic-system" className="py-20 bg-[var(--bg-primary)] scroll-mt-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
<Clock className="w-4 h-4" />
<span>{t('features:automatic.badge', 'La Funcionalidad Estrella')}</span>
</div>
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6">
{t('features:automatic.title', 'Tu Asistente Personal Que Nunca Duerme')}
</h2>
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
{t('features:automatic.intro', 'Imagina contratar un ayudante súper organizado que llega a las 5:30 AM (antes que tú) y hace todo esto AUTOMÁTICAMENTE:')}
</p>
</div>
<div className="max-w-5xl mx-auto space-y-8">
{/* Step 1 */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-8 border-2 border-blue-200 dark:border-blue-800">
<div className="flex gap-6 items-start">
<div className="w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
1
</div>
<div>
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-3">
{t('features:automatic.step1.title', 'Revisa Todo Tu Inventario')}
</h3>
<ul className="space-y-2 text-[var(--text-secondary)]">
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step1.item1', 'Cuenta cada kilo de harina, cada litro de leche')}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step1.item2', 'Comprueba fechas de caducidad')}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step1.item3', 'Ve qué llega hoy de proveedores')}</span>
</li>
</ul>
</div>
<ScrollReveal variant="fadeUp" delay={0.1}>
<div className="text-center mb-16">
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
<Clock className="w-4 h-4" />
<span>{t('features:automatic.badge', 'La Funcionalidad Estrella')}</span>
</div>
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6">
{t('features:automatic.title', 'Tu Asistente Personal Que Nunca Duerme')}
</h2>
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
{t('features:automatic.intro', 'Imagina contratar un ayudante súper organizado que llega a las 5:30 AM (antes que tú) y hace todo esto AUTOMÁTICAMENTE:')}
</p>
</div>
</ScrollReveal>
{/* Step 2 */}
<div className="bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 rounded-2xl p-8 border-2 border-purple-200 dark:border-purple-800">
<div className="flex gap-6 items-start">
<div className="w-16 h-16 bg-purple-600 rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
2
</div>
<div>
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-3">
{t('features:automatic.step2.title', 'Predice Ventas de Hoy')}
</h3>
<ul className="space-y-2 text-[var(--text-secondary)]">
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-purple-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step2.item1', 'Analiza el día (lunes lluvioso, fiesta local, colegio cerrado)')}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-purple-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step2.item2', 'Compara con días similares del pasado')}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-purple-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step2.item3', 'Te dice: "Hoy venderás 80 croissants, 120 barras, 50 magdalenas"')}</span>
</li>
</ul>
</div>
</div>
</div>
{/* Step 3 */}
<div className="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-2xl p-8 border-2 border-green-200 dark:border-green-800">
<div className="flex gap-6 items-start">
<div className="w-16 h-16 bg-green-600 rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
3
</div>
<div>
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-3">
{t('features:automatic.step3.title', 'Planifica Qué Hacer')}
</h3>
<ul className="space-y-2 text-[var(--text-secondary)]">
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-green-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step3.item1', 'Calcula exactamente cuánto hornear')}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-green-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step3.item2', 'Te da una lista lista para ejecutar')}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-green-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step3.item3', '"Haz 80 croissants (no 100), usa 5kg mantequilla, 3kg harina..."')}</span>
</li>
</ul>
</div>
</div>
</div>
{/* Step 4 - Inventory Management */}
<div className="bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 rounded-2xl p-8 border-2 border-amber-200 dark:border-amber-800">
<div className="flex gap-6 items-start">
<div className="w-16 h-16 bg-amber-600 rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
4
</div>
<div>
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-3">
{t('features:automatic.step4.title', 'Gestiona Inventario Inteligentemente')}
</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 mb-4 border border-amber-300 dark:border-amber-700">
<p className="text-sm font-medium text-[var(--text-secondary)] mb-2">{t('features:automatic.step4.projection_title', 'Proyecta 7 días hacia adelante:')}</p>
<div className="space-y-1 text-sm">
<div className="flex justify-between items-center">
<span className="text-[var(--text-secondary)]">{t('features:automatic.step4.day1', 'Hoy: 50kg harina')}</span>
<span className="text-green-600"></span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--text-secondary)]">{t('features:automatic.step4.day2', 'Mañana: 42kg')}</span>
<span className="text-green-600"></span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--text-secondary)]">{t('features:automatic.step4.day3', 'Pasado: 30kg')}</span>
<span className="text-amber-600"></span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--text-secondary)]">{t('features:automatic.step4.day4', 'Día 4: 15kg')}</span>
<span className="text-red-600">🚨</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--text-secondary)]">{t('features:automatic.step4.day5', 'Día 5: Te quedarías sin harina')}</span>
<span className="text-red-600"></span>
</div>
</div>
</div>
<div className="bg-green-100 dark:bg-green-900/30 rounded-lg p-4 border-l-4 border-green-600">
<p className="font-bold text-green-900 dark:text-green-100 mb-2">
{t('features:automatic.step4.solution_title', 'SOLUCIÓN AUTOMÁTICA:')}
</p>
<p className="text-green-800 dark:text-green-200">
{t('features:automatic.step4.solution', '"Pide 50kg hoy, llega en 3 días, problema resuelto"')}
</p>
</div>
</div>
</div>
</div>
{/* Step 5 - Purchase Orders */}
<div className="bg-gradient-to-r from-red-50 to-rose-50 dark:from-red-900/20 dark:to-rose-900/20 rounded-2xl p-8 border-2 border-red-200 dark:border-red-800">
<div className="flex gap-6 items-start">
<div className="w-16 h-16 bg-red-600 rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
5
</div>
<div>
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-3">
{t('features:automatic.step5.title', 'Crea Pedidos a Proveedores')}
</h3>
<ul className="space-y-2 text-[var(--text-secondary)]">
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step5.item1', 'Sabe que Proveedor A tarda 3 días, Proveedor B tarda 5')}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step5.item2', 'Calcula cuándo pedir para que llegue justo a tiempo')}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step5.item3', 'Prepara pedidos listos para aprobar con 1 clic')}</span>
</li>
</ul>
</div>
</div>
</div>
{/* Step 6 - Waste Prevention */}
<div className="bg-gradient-to-r from-teal-50 to-cyan-50 dark:from-teal-900/20 dark:to-cyan-900/20 rounded-2xl p-8 border-2 border-teal-200 dark:border-teal-800">
<div className="flex gap-6 items-start">
<div className="w-16 h-16 bg-teal-600 rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
6
</div>
<div>
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-3">
{t('features:automatic.step6.title', 'Previene Desperdicios')}
</h3>
<div className="space-y-3 text-[var(--text-secondary)]">
<p className="font-medium">{t('features:automatic.step6.perishables', 'Ingredientes perecederos (leche, nata, huevos):')}</p>
<ul className="space-y-2">
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-teal-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step6.item1', '"Tienes leche que caduca en 5 días"')}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-teal-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step6.item2', '"Solo usarás 15L en 5 días"')}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-teal-600 mt-0.5 flex-shrink-0" />
<span>{t('features:automatic.step6.item3', '"No pidas más de 15L, se desperdiciará"')}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<ScrollReveal variant="fadeUp" delay={0.2}>
<div className="max-w-5xl mx-auto">
{/* Automatic System Timeline */}
<StepTimeline
steps={automaticSystemSteps}
orientation="vertical"
showConnector
animated
/>
{/* Morning Result */}
<div className="bg-gradient-to-r from-[var(--color-primary)] to-orange-600 rounded-2xl p-8 text-white">
@@ -328,29 +288,33 @@ const FeaturesPage: React.FC = () => {
</div>
</div>
</div>
</div>
</div>
</ScrollReveal>
</div>
</section>
{/* Feature 2: Local Intelligence */}
<section className="py-20 bg-[var(--bg-secondary)]">
<section id="local-intelligence" className="py-20 bg-[var(--bg-secondary)] scroll-mt-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
<MapPin className="w-4 h-4" />
<span>{t('features:local.badge', 'Tu Ventaja Competitiva')}</span>
<ScrollReveal variant="fadeUp" delay={0.1}>
<div className="text-center mb-16">
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
<MapPin className="w-4 h-4" />
<span>{t('features:local.badge', 'Tu Ventaja Competitiva')}</span>
</div>
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6">
{t('features:local.title', 'Tu Panadería Es Única. La IA También.')}
</h2>
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
{t('features:local.intro', 'Las IA genéricas saben que es lunes. La TUYA sabe que:')}
</p>
</div>
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6">
{t('features:local.title', 'Tu Panadería Es Única. La IA También.')}
</h2>
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
{t('features:local.intro', 'Las IA genéricas saben que es lunes. La TUYA sabe que:')}
</p>
</div>
</ScrollReveal>
<div className="grid md:grid-cols-2 gap-8 max-w-6xl mx-auto">
{/* Schools */}
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
<ScrollReveal variant="fadeUp" delay={0.1}>
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
<div className="w-12 h-12 bg-blue-500/10 rounded-xl flex items-center justify-center mb-4">
<School className="w-6 h-6 text-blue-600" />
</div>
@@ -371,10 +335,12 @@ const FeaturesPage: React.FC = () => {
<span>{t('features:local.schools.item3', '"Los lunes a las 8:30 hay pico (padres tras dejar niños)"')}</span>
</li>
</ul>
</div>
</div>
</ScrollReveal>
{/* Offices */}
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
<ScrollReveal variant="fadeUp" delay={0.15}>
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
<div className="w-12 h-12 bg-purple-500/10 rounded-xl flex items-center justify-center mb-4">
<Building2 className="w-6 h-6 text-purple-600" />
</div>
@@ -395,10 +361,12 @@ const FeaturesPage: React.FC = () => {
<span>{t('features:local.offices.item3', '"Hora punta: 13:00-14:00 (bocadillos)"')}</span>
</li>
</ul>
</div>
</div>
</ScrollReveal>
{/* Gyms */}
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
<ScrollReveal variant="fadeUp" delay={0.2}>
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
<div className="w-12 h-12 bg-green-500/10 rounded-xl flex items-center justify-center mb-4">
<Dumbbell className="w-6 h-6 text-green-600" />
</div>
@@ -419,10 +387,12 @@ const FeaturesPage: React.FC = () => {
<span>{t('features:local.gyms.item3', '"Pico a las 7:00 AM y 19:00 PM"')}</span>
</li>
</ul>
</div>
</div>
</ScrollReveal>
{/* Competition */}
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
<ScrollReveal variant="fadeUp" delay={0.25}>
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
<div className="w-12 h-12 bg-amber-500/10 rounded-xl flex items-center justify-center mb-4">
<ShoppingBag className="w-6 h-6 text-amber-600" />
</div>
@@ -443,10 +413,12 @@ const FeaturesPage: React.FC = () => {
<span>{t('features:local.competition.item3', '"Oportunidad: Diferénciate con especialidades"')}</span>
</li>
</ul>
</div>
</div>
</ScrollReveal>
{/* Weather */}
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
<ScrollReveal variant="fadeUp" delay={0.3}>
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
<div className="w-12 h-12 bg-sky-500/10 rounded-xl flex items-center justify-center mb-4">
<Cloud className="w-6 h-6 text-sky-600" />
</div>
@@ -467,10 +439,12 @@ const FeaturesPage: React.FC = () => {
<span>{t('features:local.weather.item3', '"Calor → +30% productos frescos"')}</span>
</li>
</ul>
</div>
</div>
</ScrollReveal>
{/* Events */}
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
<ScrollReveal variant="fadeUp" delay={0.35}>
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
<div className="w-12 h-12 bg-pink-500/10 rounded-xl flex items-center justify-center mb-4">
<PartyPopper className="w-6 h-6 text-pink-600" />
</div>
@@ -491,11 +465,13 @@ const FeaturesPage: React.FC = () => {
<span>{t('features:local.events.item3', '"Partido importante → pico de ventas pre-evento"')}</span>
</li>
</ul>
</div>
</div>
</ScrollReveal>
</div>
{/* Why it matters */}
<div className="mt-12 max-w-4xl mx-auto bg-gradient-to-r from-[var(--color-primary)] to-orange-600 rounded-2xl p-8 text-white">
<ScrollReveal variant="fadeUp" delay={0.4}>
<div className="mt-12 max-w-4xl mx-auto bg-gradient-to-r from-[var(--color-primary)] to-orange-600 rounded-2xl p-8 text-white">
<h3 className="text-2xl font-bold mb-4 text-center">
{t('features:local.why_matters.title', 'Por qué importa:')}
</h3>
@@ -510,23 +486,26 @@ const FeaturesPage: React.FC = () => {
</div>
</div>
<p className="text-center mt-6 text-xl font-bold">
{t('features:local.accuracy', 'Precisión: 92% (vs 60-70% de sistemas genéricos)')}
Precisión: <AnimatedCounter value={92} suffix="%" className="inline text-[var(--color-primary)]" /> (vs 60-70% de sistemas genéricos)
</p>
</div>
</div>
</ScrollReveal>
</div>
</section>
{/* Feature 3: Demand Forecasting */}
<section className="py-20 bg-[var(--bg-primary)]">
<section id="demand-forecasting" className="py-20 bg-[var(--bg-primary)] scroll-mt-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
{t('features:forecasting.title', 'Sabe Cuánto Venderás Mañana (92% de Precisión)')}
</h2>
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
{t('features:forecasting.subtitle', 'No es magia. Es matemáticas con tus datos.')}
</p>
</div>
<ScrollReveal variant="fadeUp" delay={0.1}>
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
Sabe Cuánto Venderás Mañana (<AnimatedCounter value={92} suffix="%" className="inline text-[var(--color-primary)]" /> de Precisión)
</h2>
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
{t('features:forecasting.subtitle', 'No es magia. Es matemáticas con tus datos.')}
</p>
</div>
</ScrollReveal>
<div className="max-w-5xl mx-auto">
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-8 border-2 border-blue-200 dark:border-blue-800 mb-8">
@@ -591,17 +570,20 @@ const FeaturesPage: React.FC = () => {
</section>
{/* Feature 4: Reduce Waste = Save Money */}
<section className="py-20 bg-[var(--bg-secondary)]">
<section id="waste-reduction" className="py-20 bg-[var(--bg-secondary)] scroll-mt-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
{t('features:waste.title', 'Menos Pan en la Basura, Más Dinero en Tu Bolsillo')}
</h2>
</div>
<ScrollReveal variant="fadeUp" delay={0.1}>
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
{t('features:waste.title', 'Menos Pan en la Basura, Más Dinero en Tu Bolsillo')}
</h2>
</div>
</ScrollReveal>
<div className="max-w-5xl mx-auto grid md:grid-cols-2 gap-8">
{/* Before */}
<div className="bg-red-50 dark:bg-red-900/20 rounded-2xl p-8 border-2 border-red-200 dark:border-red-800">
<ScrollReveal variant="fadeLeft" delay={0.2}>
<div className="bg-red-50 dark:bg-red-900/20 rounded-2xl p-8 border-2 border-red-200 dark:border-red-800">
<div className="w-12 h-12 bg-red-500/20 rounded-xl flex items-center justify-center mb-4">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
@@ -615,10 +597,12 @@ const FeaturesPage: React.FC = () => {
<li className="font-bold text-red-700 dark:text-red-400">{t('features:waste.before.monthly', 'Al mes: €100 × 30 = €3,000 perdidos')}</li>
<li className="font-bold text-red-900 dark:text-red-300 text-lg">{t('features:waste.before.yearly', 'Al año: €36,000 tirados a la basura')}</li>
</ul>
</div>
</div>
</ScrollReveal>
{/* After */}
<div className="bg-green-50 dark:bg-green-900/20 rounded-2xl p-8 border-2 border-green-200 dark:border-green-800">
<ScrollReveal variant="fadeRight" delay={0.2}>
<div className="bg-green-50 dark:bg-green-900/20 rounded-2xl p-8 border-2 border-green-200 dark:border-green-800">
<div className="w-12 h-12 bg-green-500/20 rounded-xl flex items-center justify-center mb-4">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
@@ -630,29 +614,45 @@ const FeaturesPage: React.FC = () => {
<li>{t('features:waste.after.item2', 'Desperdicio: 5 × €2 = €10/día')}</li>
<li className="font-bold text-green-700 dark:text-green-400">{t('features:waste.after.monthly', 'Al mes: €300')}</li>
<li className="font-bold text-green-900 dark:text-green-300 text-xl bg-green-100 dark:bg-green-900/40 p-3 rounded-lg">
{t('features:waste.after.savings', 'AHORRO: €2,700/mes (€32,400/año)')}
AHORRO: <AnimatedCounter value={2700} prefix="€" className="inline" decimals={0} />/mes (<AnimatedCounter value={32400} prefix="€" className="inline" decimals={0} />/año)
</li>
</ul>
<p className="mt-4 text-sm font-medium text-green-700 dark:text-green-400">
{t('features:waste.after.roi', 'Recuperas la inversión en semana 1.')}
</p>
</div>
</div>
</ScrollReveal>
</div>
{/* Interactive Savings Calculator */}
<div className="mt-12 max-w-4xl mx-auto">
<ScrollReveal variant="scaleUp" delay={0.2}>
<SavingsCalculator
defaultWaste={50}
pricePerUnit={2}
wasteReduction={80}
unitName="barras"
currency="€"
/>
</ScrollReveal>
</div>
</div>
</section>
{/* Feature 5: Sustainability + Grants */}
<section className="py-20 bg-[var(--bg-primary)]">
<section id="sustainability" className="py-20 bg-[var(--bg-primary)] scroll-mt-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<div className="inline-flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 px-4 py-2 rounded-full text-sm font-medium mb-6">
<Leaf className="w-4 h-4" />
<span>{t('features:sustainability.badge', 'Funcionalidad del Sistema')}</span>
<ScrollReveal variant="fadeUp" delay={0.1}>
<div className="text-center mb-12">
<div className="inline-flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 px-4 py-2 rounded-full text-sm font-medium mb-6">
<Leaf className="w-4 h-4" />
<span>{t('features:sustainability.badge', 'Funcionalidad del Sistema')}</span>
</div>
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
{t('features:sustainability.title', 'Impacto Ambiental y Sostenibilidad')}
</h2>
</div>
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
{t('features:sustainability.title', 'Impacto Ambiental y Sostenibilidad')}
</h2>
</div>
</ScrollReveal>
{/* UN SDG Compliance */}
<div className="max-w-5xl mx-auto mb-12">
@@ -813,7 +813,7 @@ const FeaturesPage: React.FC = () => {
</section>
{/* Feature 6: Business Models */}
<section className="py-20 bg-[var(--bg-secondary)]">
<section id="business-models" className="py-20 bg-[var(--bg-secondary)] scroll-mt-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
@@ -896,6 +896,8 @@ const FeaturesPage: React.FC = () => {
</Link>
</div>
</section>
</div>
</div>
</PublicLayout>
);
};

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '../../components/ui';
import { Button, ScrollReveal, FloatingCTA, AnimatedCounter } from '../../components/ui';
import { PublicLayout } from '../../components/layout';
import { PricingSection } from '../../components/subscription';
import { getRegisterUrl, getDemoUrl } from '../../utils/navigation';
@@ -26,6 +26,7 @@ import {
const LandingPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<PublicLayout
@@ -38,26 +39,42 @@ const LandingPage: React.FC = () => {
variant: "default"
}}
>
{/* Floating CTA - appears after scrolling */}
<FloatingCTA
text={t('landing:hero.cta_primary', 'Únete al Programa Piloto')}
onClick={() => navigate(getRegisterUrl())}
icon={<ArrowRight className="w-4 h-4" />}
position="bottom-right"
showAfterScroll={600}
dismissible
/>
{/* Hero Section */}
<section className="relative py-20 lg:py-32 bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5">
<section className="relative py-20 lg:py-32 bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5 overflow-hidden">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
{/* Pre-headline */}
<div className="mb-4">
<p className="text-sm md:text-base font-medium text-[var(--text-tertiary)]">
{t('landing:hero.pre_headline', 'Para Panaderías que Pierden €500-2,000/Mes en Desperdicios')}
</p>
</div>
{/* Scarcity Badge */}
<div className="mb-6 inline-block">
<div className="bg-gradient-to-r from-amber-50 via-orange-50 to-amber-50 dark:from-amber-900/20 dark:via-orange-900/20 dark:to-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-full px-6 py-3 shadow-lg">
<div className="mb-8 inline-block">
<div className="bg-gradient-to-r from-amber-50 via-orange-50 to-amber-50 dark:from-amber-900/20 dark:via-orange-900/20 dark:to-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-full px-6 py-3 shadow-lg hover:shadow-xl transition-shadow">
<p className="text-sm font-bold text-amber-700 dark:text-amber-300">
🔥 {t('landing:hero.scarcity', 'Solo 12 plazas restantes de 20 • 3 meses GRATIS')}
</p>
</div>
</div>
<h1 className="text-4xl tracking-tight font-extrabold text-[var(--text-primary)] sm:text-5xl lg:text-7xl">
<span className="block">{t('landing:hero.title_line1', 'Aumenta Ganancias,')}</span>
<span className="block text-[var(--color-primary)]">{t('landing:hero.title_line2', 'Reduce Desperdicios')}</span>
<h1 className="text-4xl tracking-tight font-extrabold text-[var(--text-primary)] sm:text-5xl lg:text-7xl leading-tight">
<span className="block">{t('landing:hero.title_option_a_line1', 'Ahorra €500-2,000 al Mes')}</span>
<span className="block text-[var(--color-primary)] mt-2">{t('landing:hero.title_option_a_line2', 'Produciendo Exactamente Lo Que Venderás')}</span>
</h1>
<p className="mt-6 max-w-3xl mx-auto text-lg text-[var(--text-secondary)] sm:text-xl">
{t('landing:hero.subtitle', 'IA que predice demanda con datos de tu zona para que produzcas exactamente lo que vas a vender. Reduce desperdicios, mejora márgenes y ahorra tiempo.')}
<p className="mt-8 max-w-3xl mx-auto text-lg text-[var(--text-secondary)] sm:text-xl leading-relaxed">
{t('landing:hero.subtitle_option_a', 'La primera IA que conoce tu barrio: colegios cerca, clima local, tu competencia, eventos. Sistema automático cada mañana. Listo a las 6 AM.')}
</p>
{/* CTA Buttons */}
@@ -75,24 +92,48 @@ const LandingPage: React.FC = () => {
</Link>
</div>
{/* Social Proof - New */}
<div className="mt-12 max-w-3xl mx-auto">
<div className="grid md:grid-cols-3 gap-6 text-left">
<div className="flex items-start gap-3 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm p-4 rounded-xl shadow-sm border border-[var(--border-primary)]">
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
<span className="text-sm font-medium text-[var(--text-secondary)]">
<AnimatedCounter value={20} className="inline font-bold" /> panaderías ya ahorran <AnimatedCounter value={1500} prefix="€" className="inline font-bold" />/mes de promedio
</span>
</div>
<div className="flex items-start gap-3 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm p-4 rounded-xl shadow-sm border border-[var(--border-primary)]">
<Target className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<span className="text-sm font-medium text-[var(--text-secondary)]">
Predicciones <AnimatedCounter value={92} suffix="%" className="inline font-bold" /> precisas (vs 60% sistemas genéricos)
</span>
</div>
<div className="flex items-start gap-3 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm p-4 rounded-xl shadow-sm border border-[var(--border-primary)]">
<Clock className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<span className="text-sm font-medium text-[var(--text-secondary)]">
{t('landing:hero.social_proof.setup', 'Configuración en 15 minutos')}
</span>
</div>
</div>
</div>
{/* Trust Badges */}
<div className="mt-12 flex flex-wrap items-center justify-center gap-x-8 gap-y-4 text-sm">
<div className="flex items-center bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm px-4 py-2 rounded-full shadow-sm border border-[var(--border-primary)]">
<CheckCircle2 className="w-4 h-4 text-amber-600 dark:text-amber-400 mr-2" />
<span className="font-medium text-[var(--text-secondary)]">
{t('landing:hero.trust.card', 'Tarjeta requerida')}
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-8 gap-y-4 text-sm">
<div className="flex items-center">
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400 mr-2" />
<span className="font-medium text-[var(--text-tertiary)]">
{t('landing:hero.trust.no_cc', '3 meses gratis')}
</span>
</div>
<div className="flex items-center bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm px-4 py-2 rounded-full shadow-sm border border-[var(--border-primary)]">
<div className="flex items-center">
<Clock className="w-4 h-4 text-blue-600 dark:text-blue-400 mr-2" />
<span className="font-medium text-[var(--text-secondary)]">
{t('landing:hero.trust.quick', '3 meses gratis')}
<span className="font-medium text-[var(--text-tertiary)]">
{t('landing:hero.trust.quick', 'Configuración en 15 min')}
</span>
</div>
<div className="flex items-center bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm px-4 py-2 rounded-full shadow-sm border border-[var(--border-primary)]">
<Zap className="w-4 h-4 text-green-600 dark:text-green-400 mr-2" />
<span className="font-medium text-[var(--text-secondary)]">
{t('landing:hero.trust.setup', 'Configuración en 15 min')}
<div className="flex items-center">
<Shield className="w-4 h-4 text-purple-600 dark:text-purple-400 mr-2" />
<span className="font-medium text-[var(--text-tertiary)]">
{t('landing:hero.trust.card', 'Tarjeta requerida')}
</span>
</div>
</div>
@@ -105,10 +146,11 @@ const LandingPage: React.FC = () => {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid md:grid-cols-2 gap-12">
{/* Problems */}
<div>
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-8">
{t('landing:problems.title', '❌ Los Problemas Que Enfrentas')}
</h2>
<ScrollReveal variant="fadeRight" delay={0.1}>
<div>
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-8">
{t('landing:problems.title', '❌ Los Problemas Que Enfrentas')}
</h2>
<div className="space-y-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/20 rounded-lg flex items-center justify-center flex-shrink-0">
@@ -150,13 +192,15 @@ const LandingPage: React.FC = () => {
</div>
</div>
</div>
</div>
</div>
</ScrollReveal>
{/* Solutions */}
<div>
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-8">
{t('landing:solutions.title', '✅ La Solución Con IA')}
</h2>
<ScrollReveal variant="fadeLeft" delay={0.2}>
<div>
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-8">
{t('landing:solutions.title', '✅ La Solución Con IA')}
</h2>
<div className="space-y-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center flex-shrink-0">
@@ -198,7 +242,8 @@ const LandingPage: React.FC = () => {
</div>
</div>
</div>
</div>
</div>
</ScrollReveal>
</div>
</div>
</section>
@@ -279,7 +324,7 @@ const LandingPage: React.FC = () => {
<div className="mt-6 bg-gradient-to-r from-[var(--color-primary)]/10 to-orange-500/10 rounded-lg p-4 border-l-4 border-[var(--color-primary)]">
<p className="font-bold text-[var(--text-primary)]">
{t('landing:pillar1.accuracy', '🎯 Precisión: 92% (vs 60-70% de sistemas genéricos)')}
🎯 Precisión: <AnimatedCounter value={92} suffix="%" className="inline text-[var(--color-primary)]" /> (vs 60-70% de sistemas genéricos)
</p>
</div>
</div>

View File

@@ -0,0 +1,163 @@
/**
* Geocoding API Client
*
* Provides address search, autocomplete, and geocoding functionality
* using the backend Nominatim service.
*/
import { apiClient } from '../../api/client/apiClient';
const GEOCODING_BASE_URL = '/geocoding';
export interface AddressResult {
display_name: string;
lat: number;
lon: number;
osm_type: string;
osm_id: number;
place_id: number;
type: string;
class: string;
address: {
road?: string;
house_number?: string;
suburb?: string;
city?: string;
municipality?: string;
state?: string;
postcode?: string;
country?: string;
country_code?: string;
};
boundingbox: string[];
}
export interface GeocodeResult {
display_name: string;
lat: number;
lon: number;
address: {
road?: string;
house_number?: string;
suburb?: string;
city?: string;
municipality?: string;
state?: string;
postcode?: string;
country?: string;
country_code?: string;
};
}
export interface CoordinateValidation {
valid: boolean;
address?: string;
}
export const geocodingApi = {
/**
* Search for addresses matching query (autocomplete)
*
* @param query - Search query (minimum 3 characters)
* @param countryCode - ISO country code (default: 'es')
* @param limit - Maximum number of results (default: 10)
*/
async searchAddresses(
query: string,
countryCode: string = 'es',
limit: number = 10
): Promise<AddressResult[]> {
if (!query || query.trim().length < 3) {
return [];
}
const response = await apiClient.get<AddressResult[]>(
`${GEOCODING_BASE_URL}/search`,
{
params: {
q: query,
country_code: countryCode,
limit
}
}
);
return response;
},
/**
* Geocode an address to get coordinates
*
* @param address - Full address string
* @param countryCode - ISO country code (default: 'es')
*/
async geocodeAddress(
address: string,
countryCode: string = 'es'
): Promise<GeocodeResult> {
const response = await apiClient.get<GeocodeResult>(
`${GEOCODING_BASE_URL}/geocode`,
{
params: {
address,
country_code: countryCode
}
}
);
return response;
},
/**
* Reverse geocode coordinates to get address
*
* @param lat - Latitude
* @param lon - Longitude
*/
async reverseGeocode(
lat: number,
lon: number
): Promise<GeocodeResult> {
const response = await apiClient.get<GeocodeResult>(
`${GEOCODING_BASE_URL}/reverse`,
{
params: { lat, lon }
}
);
return response;
},
/**
* Validate coordinates
*
* @param lat - Latitude
* @param lon - Longitude
*/
async validateCoordinates(
lat: number,
lon: number
): Promise<CoordinateValidation> {
const response = await apiClient.get<CoordinateValidation>(
`${GEOCODING_BASE_URL}/validate`,
{
params: { lat, lon }
}
);
return response;
},
/**
* Check geocoding service health
*/
async checkHealth(): Promise<{
status: string;
service: string;
base_url: string;
is_public_api: boolean;
}> {
const response = await apiClient.get(`${GEOCODING_BASE_URL}/health`);
return response;
}
};

View File

@@ -0,0 +1,109 @@
/**
* POI Context API Client
*
* API client for POI detection and context management
*/
import { apiClient } from '../../api/client/apiClient';
import type {
POIDetectionResponse,
POIContextResponse,
FeatureImportanceResponse,
CompetitorAnalysis,
POICacheStats
} from '@/types/poi';
const POI_BASE_URL = '/poi-context';
export const poiContextApi = {
/**
* Detect POIs for a tenant's bakery location
*/
async detectPOIs(
tenantId: string,
latitude: number,
longitude: number,
forceRefresh: boolean = false
): Promise<POIDetectionResponse> {
const response = await apiClient.post<POIDetectionResponse>(
`${POI_BASE_URL}/${tenantId}/detect`,
null,
{
params: {
latitude,
longitude,
force_refresh: forceRefresh
}
}
);
return response;
},
/**
* Get POI context for a tenant
*/
async getPOIContext(tenantId: string): Promise<POIContextResponse> {
const response = await apiClient.get<POIContextResponse>(
`${POI_BASE_URL}/${tenantId}`
);
return response;
},
/**
* Refresh POI context for a tenant
*/
async refreshPOIContext(tenantId: string): Promise<POIDetectionResponse> {
const response = await apiClient.post<POIDetectionResponse>(
`${POI_BASE_URL}/${tenantId}/refresh`
);
return response;
},
/**
* Delete POI context for a tenant
*/
async deletePOIContext(tenantId: string): Promise<void> {
await apiClient.delete(`${POI_BASE_URL}/${tenantId}`);
},
/**
* Get feature importance summary
*/
async getFeatureImportance(tenantId: string): Promise<FeatureImportanceResponse> {
const response = await apiClient.get<FeatureImportanceResponse>(
`${POI_BASE_URL}/${tenantId}/feature-importance`
);
return response;
},
/**
* Get competitor analysis
*/
async getCompetitorAnalysis(tenantId: string): Promise<{
tenant_id: string;
location: { latitude: number; longitude: number };
competitor_analysis: CompetitorAnalysis;
insights: string[];
}> {
const response = await apiClient.get(
`${POI_BASE_URL}/${tenantId}/competitor-analysis`
);
return response;
},
/**
* Check POI service health
*/
async checkHealth(): Promise<{ status: string; overpass_api: any }> {
const response = await apiClient.get(`${POI_BASE_URL}/health`);
return response;
},
/**
* Get cache statistics
*/
async getCacheStats(): Promise<{ status: string; cache_stats: POICacheStats }> {
const response = await apiClient.get(`${POI_BASE_URL}/cache/stats`);
return response;
}
};

View File

@@ -68,6 +68,8 @@
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
--shadow-3xl: 0 35px 60px -15px rgba(0, 0, 0, 0.3);
/* Typography */
--font-family-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
@@ -120,8 +122,13 @@
/* Transition */
--transition-fast: 150ms ease-in-out;
--transition-base: 200ms ease-in-out;
--transition-normal: 300ms ease-in-out;
--transition-slow: 500ms ease-in-out;
/* Animation Timing Functions */
--ease-in-out-smooth: cubic-bezier(0.25, 0.1, 0.25, 1);
--ease-spring: cubic-bezier(0.68, -0.55, 0.265, 1.55);
/* Layout */
--container-max-width: 1280px;

247
frontend/src/types/poi.ts Normal file
View File

@@ -0,0 +1,247 @@
/**
* POI (Point of Interest) Type Definitions
*
* Types for POI detection, context, and visualization
*/
export interface POILocation {
latitude: number;
longitude: number;
}
export interface POI {
osm_id: string;
type: 'node' | 'way';
lat: number;
lon: number;
tags: Record<string, string>;
name: string;
distance_m?: number;
zone?: string;
}
export interface POIFeatures {
proximity_score: number;
weighted_proximity_score: number;
count_0_100m: number;
count_100_300m: number;
count_300_500m: number;
count_500_1000m: number;
total_count: number;
distance_to_nearest_m: number;
has_within_100m: boolean;
has_within_300m: boolean;
has_within_500m: boolean;
}
export interface POICategoryData {
pois: POI[];
features: POIFeatures;
count: number;
error?: string;
}
export interface POICategories {
schools: POICategoryData;
offices: POICategoryData;
gyms_sports: POICategoryData;
residential: POICategoryData;
tourism: POICategoryData;
competitors: POICategoryData;
transport_hubs: POICategoryData;
coworking: POICategoryData;
retail: POICategoryData;
}
export interface POISummary {
total_pois_detected: number;
categories_with_pois: string[];
high_impact_categories: string[];
categories_count: number;
}
export interface CompetitorAnalysis {
competitive_pressure_score: number;
direct_competitors_count: number;
nearby_competitors_count: number;
market_competitors_count: number;
total_competitors_count: number;
competitive_zone: 'low_competition' | 'moderate_competition' | 'high_competition';
market_type: 'underserved' | 'normal_market' | 'competitive_market' | 'bakery_district';
competitive_advantage: 'first_mover' | 'local_leader' | 'quality_focused' | 'differentiation_required';
competitor_details: POI[];
nearest_competitor: POI | null;
}
export interface POIContext {
id: string;
tenant_id: string;
location: POILocation;
poi_detection_results: POICategories;
ml_features: Record<string, number>;
total_pois_detected: number;
high_impact_categories: string[];
relevant_categories: string[];
detection_timestamp: string;
detection_source: string;
detection_status: 'completed' | 'partial' | 'failed';
detection_error?: string;
next_refresh_date?: string;
last_refreshed_at?: string;
created_at: string;
updated_at: string;
}
export interface RelevanceReportItem {
category: string;
relevant: boolean;
reason: string;
proximity_score: number;
count: number;
distance_to_nearest_m: number;
}
export interface POIDetectionResponse {
status: 'success' | 'error';
source: 'detection' | 'cache';
poi_context: POIContext;
feature_selection?: {
features: Record<string, number>;
relevant_categories: string[];
relevance_report: RelevanceReportItem[];
total_features: number;
total_relevant_categories: number;
};
competitor_analysis?: CompetitorAnalysis;
competitive_insights?: string[];
}
export interface POIContextResponse {
poi_context: POIContext;
is_stale: boolean;
needs_refresh: boolean;
}
export interface FeatureImportanceItem {
category: string;
is_relevant: boolean;
proximity_score: number;
weighted_score: number;
total_count: number;
distance_to_nearest_m: number;
has_within_100m: boolean;
rejection_reason?: string;
}
export interface FeatureImportanceResponse {
tenant_id: string;
feature_importance: FeatureImportanceItem[];
total_categories: number;
relevant_categories: number;
}
export interface POICacheStats {
total_cached_locations: number;
cache_ttl_days: number;
coordinate_precision: number;
}
// Category metadata for UI display
export interface POICategoryMetadata {
name: string;
displayName: string;
icon: string;
color: string;
description: string;
}
export const POI_CATEGORY_METADATA: Record<string, POICategoryMetadata> = {
schools: {
name: 'schools',
displayName: 'Schools',
icon: '🏫',
color: '#22c55e',
description: 'Educational institutions causing morning/afternoon rush patterns'
},
offices: {
name: 'offices',
displayName: 'Offices',
icon: '🏢',
color: '#3b82f6',
description: 'Office buildings and business centers'
},
gyms_sports: {
name: 'gyms_sports',
displayName: 'Gyms & Sports',
icon: '🏋️',
color: '#8b5cf6',
description: 'Fitness centers and sports facilities'
},
residential: {
name: 'residential',
displayName: 'Residential',
icon: '🏘️',
color: '#64748b',
description: 'Residential buildings and housing'
},
tourism: {
name: 'tourism',
displayName: 'Tourism',
icon: '🗼',
color: '#f59e0b',
description: 'Tourist attractions, hotels, and points of interest'
},
competitors: {
name: 'competitors',
displayName: 'Competitors',
icon: '🥖',
color: '#ef4444',
description: 'Competing bakeries and pastry shops'
},
transport_hubs: {
name: 'transport_hubs',
displayName: 'Transport Hubs',
icon: '🚇',
color: '#06b6d4',
description: 'Public transport stations and hubs'
},
coworking: {
name: 'coworking',
displayName: 'Coworking',
icon: '💼',
color: '#84cc16',
description: 'Coworking spaces and shared offices'
},
retail: {
name: 'retail',
displayName: 'Retail',
icon: '🛍️',
color: '#ec4899',
description: 'Retail shops and commercial areas'
}
};
export const IMPACT_LEVELS = {
HIGH: { label: 'High Impact', color: '#22c55e', threshold: 2.0 },
MODERATE: { label: 'Moderate Impact', color: '#f59e0b', threshold: 1.0 },
LOW: { label: 'Low Impact', color: '#64748b', threshold: 0 }
} as const;
export type ImpactLevel = keyof typeof IMPACT_LEVELS;
export function getImpactLevel(proximityScore: number): ImpactLevel {
if (proximityScore >= IMPACT_LEVELS.HIGH.threshold) return 'HIGH';
if (proximityScore >= IMPACT_LEVELS.MODERATE.threshold) return 'MODERATE';
return 'LOW';
}
export function formatDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)}m`;
}
return `${(meters / 1000).toFixed(1)}km`;
}
export function formatCategoryName(category: string): string {
return POI_CATEGORY_METADATA[category]?.displayName || category.replace(/_/g, ' ');
}