Improve the frontend 3

This commit is contained in:
Urtzi Alfaro
2025-10-30 21:08:07 +01:00
parent 36217a2729
commit 63f5c6d512
184 changed files with 21512 additions and 7442 deletions

View File

@@ -4,7 +4,7 @@
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { showToast } from '../../utils/toast';
import { equipmentService } from '../services/equipment';
import type { Equipment, EquipmentDeletionSummary } from '../types/equipment';
@@ -74,11 +74,11 @@ export function useCreateEquipment(tenantId: string) {
newEquipment
);
toast.success('Equipment created successfully');
showToast.success('Equipment created successfully');
},
onError: (error: any) => {
console.error('Error creating equipment:', error);
toast.error(error.response?.data?.detail || 'Error creating equipment');
showToast.error(error.response?.data?.detail || 'Error creating equipment');
},
});
}
@@ -104,11 +104,11 @@ export function useUpdateEquipment(tenantId: string) {
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: equipmentKeys.lists() });
toast.success('Equipment updated successfully');
showToast.success('Equipment updated successfully');
},
onError: (error: any) => {
console.error('Error updating equipment:', error);
toast.error(error.response?.data?.detail || 'Error updating equipment');
showToast.error(error.response?.data?.detail || 'Error updating equipment');
},
});
}
@@ -131,11 +131,11 @@ export function useDeleteEquipment(tenantId: string) {
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: equipmentKeys.lists() });
toast.success('Equipment deleted successfully');
showToast.success('Equipment deleted successfully');
},
onError: (error: any) => {
console.error('Error deleting equipment:', error);
toast.error(error.response?.data?.detail || 'Error deleting equipment');
showToast.error(error.response?.data?.detail || 'Error deleting equipment');
},
});
}
@@ -158,11 +158,11 @@ export function useHardDeleteEquipment(tenantId: string) {
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: equipmentKeys.lists() });
toast.success('Equipment permanently deleted');
showToast.success('Equipment permanently deleted');
},
onError: (error: any) => {
console.error('Error hard deleting equipment:', error);
toast.error(error.response?.data?.detail || 'Error permanently deleting equipment');
showToast.error(error.response?.data?.detail || 'Error permanently deleting equipment');
},
});
}

View File

@@ -0,0 +1,28 @@
/**
* Orchestrator React Query hooks
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import * as orchestratorService from '../services/orchestrator';
import { ApiError } from '../client';
// Mutations
export const useRunDailyWorkflow = (
options?: Parameters<typeof useMutation>[0]
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tenantId: string) =>
orchestratorService.runDailyWorkflow(tenantId),
onSuccess: (_, tenantId) => {
// Invalidate queries to refresh dashboard data after workflow execution
queryClient.invalidateQueries({ queryKey: ['procurement', 'plans'] });
queryClient.invalidateQueries({ queryKey: ['production', 'batches'] });
queryClient.invalidateQueries({ queryKey: ['forecasts'] });
// Also invalidate dashboard queries to refresh stats
queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
},
...options,
});
};

View File

@@ -4,7 +4,7 @@
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { showToast } from '../../utils/toast';
import { qualityTemplateService } from '../services/qualityTemplates';
import type {
QualityCheckTemplate,
@@ -114,11 +114,11 @@ export function useCreateQualityTemplate(tenantId: string) {
newTemplate
);
toast.success('Plantilla de calidad creada exitosamente');
showToast.success('Plantilla de calidad creada exitosamente');
},
onError: (error: any) => {
console.error('Error creating quality template:', error);
toast.error(error.response?.data?.detail || 'Error al crear la plantilla de calidad');
showToast.error(error.response?.data?.detail || 'Error al crear la plantilla de calidad');
},
});
}
@@ -144,11 +144,11 @@ export function useUpdateQualityTemplate(tenantId: string) {
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: qualityTemplateKeys.lists() });
toast.success('Plantilla de calidad actualizada exitosamente');
showToast.success('Plantilla de calidad actualizada exitosamente');
},
onError: (error: any) => {
console.error('Error updating quality template:', error);
toast.error(error.response?.data?.detail || 'Error al actualizar la plantilla de calidad');
showToast.error(error.response?.data?.detail || 'Error al actualizar la plantilla de calidad');
},
});
}
@@ -171,11 +171,11 @@ export function useDeleteQualityTemplate(tenantId: string) {
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: qualityTemplateKeys.lists() });
toast.success('Plantilla de calidad eliminada exitosamente');
showToast.success('Plantilla de calidad eliminada exitosamente');
},
onError: (error: any) => {
console.error('Error deleting quality template:', error);
toast.error(error.response?.data?.detail || 'Error al eliminar la plantilla de calidad');
showToast.error(error.response?.data?.detail || 'Error al eliminar la plantilla de calidad');
},
});
}
@@ -199,11 +199,11 @@ export function useDuplicateQualityTemplate(tenantId: string) {
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: qualityTemplateKeys.lists() });
toast.success('Plantilla de calidad duplicada exitosamente');
showToast.success('Plantilla de calidad duplicada exitosamente');
},
onError: (error: any) => {
console.error('Error duplicating quality template:', error);
toast.error(error.response?.data?.detail || 'Error al duplicar la plantilla de calidad');
showToast.error(error.response?.data?.detail || 'Error al duplicar la plantilla de calidad');
},
});
}
@@ -233,14 +233,14 @@ export function useExecuteQualityCheck(tenantId: string) {
: 'Control de calidad completado con observaciones';
if (result.overall_pass) {
toast.success(message);
showToast.success(message);
} else {
toast.error(message);
showToast.error(message);
}
},
onError: (error: any) => {
console.error('Error executing quality check:', error);
toast.error(error.response?.data?.detail || 'Error al ejecutar el control de calidad');
showToast.error(error.response?.data?.detail || 'Error al ejecutar el control de calidad');
},
});
}

View File

@@ -6,7 +6,7 @@
import { useQuery, useMutation, useQueryClient, UseQueryOptions } from '@tanstack/react-query';
import { settingsApi } from '../services/settings';
import { useToast } from '../../hooks/ui/useToast';
import { showToast } from '../../utils/toast';
import type {
TenantSettings,
TenantSettingsUpdate,
@@ -58,7 +58,6 @@ export const useCategorySettings = (
*/
export const useUpdateSettings = () => {
const queryClient = useQueryClient();
const { addToast } = useToast();
return useMutation<
TenantSettings,
@@ -69,11 +68,11 @@ export const useUpdateSettings = () => {
onSuccess: (data, variables) => {
// Invalidate all settings queries for this tenant
queryClient.invalidateQueries({ queryKey: settingsKeys.tenant(variables.tenantId) });
addToast('Ajustes guardados correctamente', { type: 'success' });
showToast.success('Ajustes guardados correctamente');
},
onError: (error) => {
console.error('Failed to update settings:', error);
addToast('Error al guardar los ajustes', { type: 'error' });
showToast.error('Error al guardar los ajustes');
},
});
};
@@ -83,7 +82,6 @@ export const useUpdateSettings = () => {
*/
export const useUpdateCategorySettings = () => {
const queryClient = useQueryClient();
const { addToast } = useToast();
return useMutation<
TenantSettings,
@@ -99,11 +97,11 @@ export const useUpdateCategorySettings = () => {
queryClient.invalidateQueries({
queryKey: settingsKeys.category(variables.tenantId, variables.category),
});
addToast('Ajustes de categoría guardados correctamente', { type: 'success' });
showToast.success('Ajustes de categoría guardados correctamente');
},
onError: (error) => {
console.error('Failed to update category settings:', error);
addToast('Error al guardar los ajustes de categoría', { type: 'error' });
showToast.error('Error al guardar los ajustes de categoría');
},
});
};
@@ -113,7 +111,6 @@ export const useUpdateCategorySettings = () => {
*/
export const useResetCategory = () => {
const queryClient = useQueryClient();
const { addToast } = useToast();
return useMutation<
CategoryResetResponse,
@@ -128,13 +125,11 @@ export const useResetCategory = () => {
queryClient.invalidateQueries({
queryKey: settingsKeys.category(variables.tenantId, variables.category),
});
addToast(`Categoría '${variables.category}' restablecida a valores predeterminados`, {
type: 'success',
});
showToast.success(`Categoría '${variables.category}' restablecida a valores predeterminados`);
},
onError: (error) => {
console.error('Failed to reset category:', error);
addToast('Error al restablecer la categoría', { type: 'error' });
showToast.error('Error al restablecer la categoría');
},
});
};

View File

@@ -44,7 +44,7 @@ export const useSubscription = () => {
const currentTenant = useCurrentTenant();
const user = useAuthUser();
const tenantId = currentTenant?.id || user?.tenant_id;
const { notifySubscriptionChanged } = useSubscriptionEvents();
const { notifySubscriptionChanged, subscriptionVersion } = useSubscriptionEvents();
// Load subscription data
const loadSubscriptionData = useCallback(async () => {
@@ -64,9 +64,6 @@ export const useSubscription = () => {
features: usageSummary.usage || {},
loading: false,
});
// Notify subscribers that subscription data has changed
notifySubscriptionChanged();
} catch (error) {
console.error('Error loading subscription data:', error);
setSubscriptionInfo(prev => ({
@@ -79,7 +76,7 @@ export const useSubscription = () => {
useEffect(() => {
loadSubscriptionData();
}, [loadSubscriptionData]);
}, [loadSubscriptionData, subscriptionVersion]);
// Check if user has a specific feature
const hasFeature = useCallback(async (featureName: string): Promise<SubscriptionFeature> => {

View File

@@ -26,6 +26,10 @@ export { productionService } from './services/production';
export { posService } from './services/pos';
export { recipesService } from './services/recipes';
// NEW: Sprint 2 & 3 Services
export * as procurementService from './services/procurement';
export * as orchestratorService from './services/orchestrator';
// Types - Auth
export type {
User,
@@ -701,4 +705,9 @@ export {
recipesKeys,
} from './hooks/recipes';
// Note: All query key factories are already exported in their respective hook sections above
// Hooks - Orchestrator
export {
useRunDailyWorkflow,
} from './hooks/orchestrator';
// Note: All query key factories are already exported in their respective hook sections above

View File

@@ -0,0 +1,341 @@
/**
* Orchestrator Service API Client
* Handles coordinated workflows across Forecasting, Production, and Procurement services
*
* NEW in Sprint 2: Orchestrator Service coordinates the daily workflow:
* 1. Forecasting Service → Get demand forecasts
* 2. Production Service → Generate production schedule from forecast
* 3. Procurement Service → Generate procurement plan from forecast + schedule
*/
import { apiClient } from '../client';
// ============================================================================
// ORCHESTRATOR WORKFLOW TYPES
// ============================================================================
export interface OrchestratorWorkflowRequest {
target_date?: string; // YYYY-MM-DD, defaults to tomorrow
planning_horizon_days?: number; // Default: 14
// Forecasting options
forecast_days_ahead?: number; // Default: 7
// Production options
auto_schedule_production?: boolean; // Default: true
production_planning_days?: number; // Default: 1
// Procurement options
auto_create_purchase_orders?: boolean; // Default: true
auto_approve_purchase_orders?: boolean; // Default: false
safety_stock_percentage?: number; // Default: 20.00
// Orchestrator options
skip_on_error?: boolean; // Continue to next step if one fails
notify_on_completion?: boolean; // Send notification when done
}
export interface WorkflowStepResult {
step: 'forecasting' | 'production' | 'procurement';
status: 'success' | 'failed' | 'skipped';
duration_ms: number;
data?: any;
error?: string;
warnings?: string[];
}
export interface OrchestratorWorkflowResponse {
success: boolean;
workflow_id: string;
tenant_id: string;
target_date: string;
execution_date: string;
total_duration_ms: number;
steps: WorkflowStepResult[];
// Step-specific results
forecast_result?: {
forecast_id: string;
total_forecasts: number;
forecast_data: any;
};
production_result?: {
schedule_id: string;
total_batches: number;
total_quantity: number;
};
procurement_result?: {
plan_id: string;
total_requirements: number;
total_cost: string;
purchase_orders_created: number;
purchase_orders_auto_approved: number;
};
warnings?: string[];
errors?: string[];
}
export interface WorkflowExecutionSummary {
id: string;
tenant_id: string;
target_date: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
started_at: string;
completed_at?: string;
total_duration_ms?: number;
steps_completed: number;
steps_total: number;
created_by?: string;
}
export interface WorkflowExecutionDetail extends WorkflowExecutionSummary {
steps: WorkflowStepResult[];
forecast_id?: string;
production_schedule_id?: string;
procurement_plan_id?: string;
warnings?: string[];
errors?: string[];
}
// ============================================================================
// ORCHESTRATOR WORKFLOW API FUNCTIONS
// ============================================================================
/**
* Run the daily orchestrated workflow
* This is the main entry point for coordinated planning
*
* Workflow:
* 1. Forecasting Service: Get demand forecasts for target date
* 2. Production Service: Generate production schedule from forecast
* 3. Procurement Service: Generate procurement plan from forecast + schedule
*
* NEW in Sprint 2: Replaces autonomous schedulers with centralized orchestration
*/
export async function runDailyWorkflow(
tenantId: string,
request?: OrchestratorWorkflowRequest
): Promise<OrchestratorWorkflowResponse> {
return apiClient.post<OrchestratorWorkflowResponse>(
`/tenants/${tenantId}/orchestrator/run-daily-workflow`,
request || {}
);
}
/**
* Run workflow for a specific date
*/
export async function runWorkflowForDate(
tenantId: string,
targetDate: string,
options?: Omit<OrchestratorWorkflowRequest, 'target_date'>
): Promise<OrchestratorWorkflowResponse> {
return runDailyWorkflow(tenantId, {
...options,
target_date: targetDate
});
}
/**
* Test workflow with sample data (for development/testing)
*/
export async function testWorkflow(
tenantId: string
): Promise<OrchestratorWorkflowResponse> {
return apiClient.post<OrchestratorWorkflowResponse>(
`/tenants/${tenantId}/orchestrator/test-workflow`,
{}
);
}
/**
* Get list of workflow executions
*/
export async function listWorkflowExecutions(
tenantId: string,
params?: {
status?: WorkflowExecutionSummary['status'];
date_from?: string;
date_to?: string;
limit?: number;
offset?: number;
}
): Promise<WorkflowExecutionSummary[]> {
return apiClient.get<WorkflowExecutionSummary[]>(
`/tenants/${tenantId}/orchestrator/executions`,
{ params }
);
}
/**
* Get a single workflow execution by ID with full details
*/
export async function getWorkflowExecution(
tenantId: string,
executionId: string
): Promise<WorkflowExecutionDetail> {
return apiClient.get<WorkflowExecutionDetail>(
`/tenants/${tenantId}/orchestrator/executions/${executionId}`
);
}
/**
* Get latest workflow execution
*/
export async function getLatestWorkflowExecution(
tenantId: string
): Promise<WorkflowExecutionDetail | null> {
const executions = await listWorkflowExecutions(tenantId, {
limit: 1
});
if (executions.length === 0) {
return null;
}
return getWorkflowExecution(tenantId, executions[0].id);
}
/**
* Cancel a running workflow execution
*/
export async function cancelWorkflowExecution(
tenantId: string,
executionId: string
): Promise<{ message: string }> {
return apiClient.post<{ message: string }>(
`/tenants/${tenantId}/orchestrator/executions/${executionId}/cancel`,
{}
);
}
/**
* Retry a failed workflow execution
*/
export async function retryWorkflowExecution(
tenantId: string,
executionId: string
): Promise<OrchestratorWorkflowResponse> {
return apiClient.post<OrchestratorWorkflowResponse>(
`/tenants/${tenantId}/orchestrator/executions/${executionId}/retry`,
{}
);
}
// ============================================================================
// ORCHESTRATOR STATUS & HEALTH
// ============================================================================
export interface OrchestratorStatus {
is_leader: boolean;
scheduler_running: boolean;
next_scheduled_run?: string;
last_execution?: {
id: string;
target_date: string;
status: string;
completed_at: string;
};
total_executions_today: number;
total_successful_executions: number;
total_failed_executions: number;
}
/**
* Get orchestrator service status
*/
export async function getOrchestratorStatus(
tenantId: string
): Promise<OrchestratorStatus> {
return apiClient.get<OrchestratorStatus>(
`/tenants/${tenantId}/orchestrator/status`
);
}
// ============================================================================
// ORCHESTRATOR CONFIGURATION
// ============================================================================
export interface OrchestratorConfig {
enabled: boolean;
schedule_cron: string; // Cron expression for daily run
default_planning_horizon_days: number;
auto_create_purchase_orders: boolean;
auto_approve_purchase_orders: boolean;
safety_stock_percentage: number;
notify_on_completion: boolean;
notify_on_failure: boolean;
skip_on_error: boolean;
}
/**
* Get orchestrator configuration for tenant
*/
export async function getOrchestratorConfig(
tenantId: string
): Promise<OrchestratorConfig> {
return apiClient.get<OrchestratorConfig>(
`/tenants/${tenantId}/orchestrator/config`
);
}
/**
* Update orchestrator configuration
*/
export async function updateOrchestratorConfig(
tenantId: string,
config: Partial<OrchestratorConfig>
): Promise<OrchestratorConfig> {
return apiClient.put<OrchestratorConfig>(
`/tenants/${tenantId}/orchestrator/config`,
config
);
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Format workflow duration for display
*/
export function formatWorkflowDuration(durationMs: number): string {
if (durationMs < 1000) {
return `${durationMs}ms`;
} else if (durationMs < 60000) {
return `${(durationMs / 1000).toFixed(1)}s`;
} else {
const minutes = Math.floor(durationMs / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
}
/**
* Get workflow step status icon
*/
export function getWorkflowStepStatusIcon(status: WorkflowStepResult['status']): string {
switch (status) {
case 'success': return '✅';
case 'failed': return '❌';
case 'skipped': return '⏭️';
default: return '❓';
}
}
/**
* Get workflow overall status color
*/
export function getWorkflowStatusColor(status: WorkflowExecutionSummary['status']): string {
switch (status) {
case 'completed': return 'green';
case 'running': return 'blue';
case 'failed': return 'red';
case 'cancelled': return 'gray';
default: return 'gray';
}
}

View File

@@ -0,0 +1,317 @@
/**
* Procurement Service API Client
* Handles procurement planning and purchase order management
*
* NEW in Sprint 3: Procurement Service now owns all procurement operations
* Previously these were split between Orders Service and Suppliers Service
*/
import { apiClient } from '../client';
// ============================================================================
// PROCUREMENT PLAN TYPES
// ============================================================================
export interface ProcurementRequirement {
id: string;
ingredient_id: string;
ingredient_name?: string;
ingredient_sku?: string;
required_quantity: number;
current_stock: number;
quantity_to_order: number;
unit_of_measure: string;
estimated_cost: string; // Decimal as string
priority: 'urgent' | 'high' | 'normal' | 'low';
reason: string;
supplier_id?: string;
supplier_name?: string;
expected_delivery_date?: string;
// NEW: Local production support
is_locally_produced?: boolean;
recipe_id?: string;
parent_requirement_id?: string;
bom_explosion_level?: number;
}
export interface ProcurementPlanSummary {
id: string;
plan_date: string;
status: 'DRAFT' | 'PENDING_APPROVAL' | 'APPROVED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED';
total_requirements: number;
total_estimated_cost: string; // Decimal as string
planning_horizon_days: number;
auto_generated: boolean;
// NEW: Orchestrator integration
forecast_id?: string;
production_schedule_id?: string;
created_at: string;
created_by?: string;
}
export interface ProcurementPlanDetail extends ProcurementPlanSummary {
requirements: ProcurementRequirement[];
notes?: string;
approved_by?: string;
approved_at?: string;
updated_at: string;
}
// ============================================================================
// AUTO-GENERATE PROCUREMENT TYPES (Orchestrator Integration)
// ============================================================================
export interface AutoGenerateProcurementRequest {
forecast_data: Record<string, any>; // From Forecasting Service
production_schedule_id?: string;
target_date?: string; // YYYY-MM-DD
planning_horizon_days?: number; // Default: 14
safety_stock_percentage?: number; // Default: 20.00
auto_create_pos?: boolean; // Default: true
auto_approve_pos?: boolean; // Default: false
}
export interface AutoGenerateProcurementResponse {
success: boolean;
plan?: ProcurementPlanDetail;
purchase_orders_created?: number;
purchase_orders_auto_approved?: number;
purchase_orders_pending_approval?: number;
recipe_explosion_applied?: boolean;
recipe_explosion_metadata?: {
total_requirements_before: number;
total_requirements_after: number;
explosion_levels: number;
locally_produced_ingredients: number;
};
warnings?: string[];
errors?: string[];
execution_time_ms?: number;
}
// ============================================================================
// PROCUREMENT PLAN API FUNCTIONS
// ============================================================================
/**
* Get list of procurement plans with optional filters
*/
export async function listProcurementPlans(
tenantId: string,
params?: {
status?: ProcurementPlanSummary['status'];
date_from?: string;
date_to?: string;
limit?: number;
offset?: number;
}
): Promise<ProcurementPlanSummary[]> {
return apiClient.get<ProcurementPlanSummary[]>(
`/tenants/${tenantId}/procurement/plans`,
{ params }
);
}
/**
* Get a single procurement plan by ID with full details
*/
export async function getProcurementPlan(
tenantId: string,
planId: string
): Promise<ProcurementPlanDetail> {
return apiClient.get<ProcurementPlanDetail>(
`/tenants/${tenantId}/procurement/plans/${planId}`
);
}
/**
* Create a new procurement plan (manual)
*/
export async function createProcurementPlan(
tenantId: string,
data: {
plan_date: string;
planning_horizon_days?: number;
include_safety_stock?: boolean;
safety_stock_percentage?: number;
notes?: string;
}
): Promise<ProcurementPlanDetail> {
return apiClient.post<ProcurementPlanDetail>(
`/tenants/${tenantId}/procurement/plans`,
data
);
}
/**
* Update procurement plan
*/
export async function updateProcurementPlan(
tenantId: string,
planId: string,
data: {
status?: ProcurementPlanSummary['status'];
notes?: string;
}
): Promise<ProcurementPlanDetail> {
return apiClient.put<ProcurementPlanDetail>(
`/tenants/${tenantId}/procurement/plans/${planId}`,
data
);
}
/**
* Delete procurement plan
*/
export async function deleteProcurementPlan(
tenantId: string,
planId: string
): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(
`/tenants/${tenantId}/procurement/plans/${planId}`
);
}
/**
* Approve procurement plan
*/
export async function approveProcurementPlan(
tenantId: string,
planId: string,
notes?: string
): Promise<ProcurementPlanDetail> {
return apiClient.post<ProcurementPlanDetail>(
`/tenants/${tenantId}/procurement/plans/${planId}/approve`,
{ notes }
);
}
// ============================================================================
// AUTO-GENERATE PROCUREMENT (ORCHESTRATOR INTEGRATION)
// ============================================================================
/**
* Auto-generate procurement plan from forecast data
* This is the main entry point for orchestrated procurement planning
*
* NEW in Sprint 3: Called by Orchestrator Service to create procurement plans
* based on forecast data and production schedules
*
* Features:
* - Receives forecast data from Forecasting Service (via Orchestrator)
* - Calculates procurement requirements using smart calculator
* - Applies Recipe Explosion for locally-produced ingredients
* - Optionally creates purchase orders
* - Optionally auto-approves qualifying POs
*/
export async function autoGenerateProcurement(
tenantId: string,
request: AutoGenerateProcurementRequest
): Promise<AutoGenerateProcurementResponse> {
return apiClient.post<AutoGenerateProcurementResponse>(
`/tenants/${tenantId}/procurement/auto-generate`,
request
);
}
/**
* Test auto-generate with sample forecast data (for development/testing)
*/
export async function testAutoGenerateProcurement(
tenantId: string,
targetDate?: string
): Promise<AutoGenerateProcurementResponse> {
return apiClient.post<AutoGenerateProcurementResponse>(
`/tenants/${tenantId}/procurement/auto-generate/test`,
{ target_date: targetDate }
);
}
// ============================================================================
// PROCUREMENT REQUIREMENTS API FUNCTIONS
// ============================================================================
/**
* Add requirement to procurement plan
*/
export async function addProcurementRequirement(
tenantId: string,
planId: string,
requirement: {
ingredient_id: string;
required_quantity: number;
quantity_to_order: number;
priority: ProcurementRequirement['priority'];
reason: string;
supplier_id?: string;
expected_delivery_date?: string;
}
): Promise<ProcurementRequirement> {
return apiClient.post<ProcurementRequirement>(
`/tenants/${tenantId}/procurement/plans/${planId}/requirements`,
requirement
);
}
/**
* Update procurement requirement
*/
export async function updateProcurementRequirement(
tenantId: string,
planId: string,
requirementId: string,
data: {
quantity_to_order?: number;
priority?: ProcurementRequirement['priority'];
supplier_id?: string;
expected_delivery_date?: string;
}
): Promise<ProcurementRequirement> {
return apiClient.put<ProcurementRequirement>(
`/tenants/${tenantId}/procurement/plans/${planId}/requirements/${requirementId}`,
data
);
}
/**
* Delete procurement requirement
*/
export async function deleteProcurementRequirement(
tenantId: string,
planId: string,
requirementId: string
): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(
`/tenants/${tenantId}/procurement/plans/${planId}/requirements/${requirementId}`
);
}
// ============================================================================
// PURCHASE ORDERS FROM PLAN
// ============================================================================
/**
* Create purchase orders from procurement plan
* Groups requirements by supplier and creates POs
*/
export async function createPurchaseOrdersFromPlan(
tenantId: string,
planId: string,
options?: {
auto_approve?: boolean;
group_by_supplier?: boolean;
delivery_date?: string;
}
): Promise<{
success: boolean;
purchase_orders_created: number;
purchase_orders_auto_approved?: number;
purchase_orders_pending_approval?: number;
purchase_order_ids: string[];
message?: string;
}> {
return apiClient.post(
`/tenants/${tenantId}/procurement/plans/${planId}/create-purchase-orders`,
options
);
}

View File

@@ -1,6 +1,10 @@
/**
* Purchase Orders API Client
* Handles all API calls for purchase orders in the suppliers service
* Handles all API calls for purchase orders
*
* UPDATED in Sprint 3: Purchase orders now managed by Procurement Service
* Previously: Suppliers Service (/tenants/{id}/purchase-orders)
* Now: Procurement Service (/tenants/{id}/procurement/purchase-orders)
*/
import { apiClient } from '../client';
@@ -126,7 +130,7 @@ export async function listPurchaseOrders(
params?: PurchaseOrderSearchParams
): Promise<PurchaseOrderSummary[]> {
return apiClient.get<PurchaseOrderSummary[]>(
`/tenants/${tenantId}/purchase-orders`,
`/tenants/${tenantId}/procurement/purchase-orders`,
{ params }
);
}
@@ -160,7 +164,7 @@ export async function getPurchaseOrder(
poId: string
): Promise<PurchaseOrderDetail> {
return apiClient.get<PurchaseOrderDetail>(
`/tenants/${tenantId}/purchase-orders/${poId}`
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`
);
}
@@ -173,7 +177,7 @@ export async function updatePurchaseOrder(
data: PurchaseOrderUpdateData
): Promise<PurchaseOrderDetail> {
return apiClient.put<PurchaseOrderDetail>(
`/tenants/${tenantId}/purchase-orders/${poId}`,
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`,
data
);
}
@@ -187,7 +191,7 @@ export async function approvePurchaseOrder(
notes?: string
): Promise<PurchaseOrderDetail> {
return apiClient.post<PurchaseOrderDetail>(
`/tenants/${tenantId}/purchase-orders/${poId}/approve`,
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/approve`,
{
action: 'approve',
notes: notes || 'Approved from dashboard'
@@ -204,7 +208,7 @@ export async function rejectPurchaseOrder(
reason: string
): Promise<PurchaseOrderDetail> {
return apiClient.post<PurchaseOrderDetail>(
`/tenants/${tenantId}/purchase-orders/${poId}/approve`,
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/approve`,
{
action: 'reject',
notes: reason
@@ -234,6 +238,6 @@ export async function deletePurchaseOrder(
poId: string
): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(
`/tenants/${tenantId}/purchase-orders/${poId}`
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`
);
}

View File

@@ -42,6 +42,15 @@ export class SubscriptionService {
// NEW METHODS - Centralized Plans API
// ============================================================================
/**
* Invalidate cached plan data
* Call this when subscription changes to ensure fresh data on next fetch
*/
invalidateCache(): void {
cachedPlans = null;
lastFetchTime = null;
}
/**
* Fetch available subscription plans with complete metadata
* Uses cached data if available and fresh (5 min cache)

View File

@@ -85,6 +85,42 @@ export interface OrderSettings {
delivery_tracking_enabled: boolean;
}
export interface ReplenishmentSettings {
projection_horizon_days: number;
service_level: number;
buffer_days: number;
enable_auto_replenishment: boolean;
min_order_quantity: number;
max_order_quantity: number;
demand_forecast_days: number;
}
export interface SafetyStockSettings {
service_level: number;
method: string;
min_safety_stock: number;
max_safety_stock: number;
reorder_point_calculation: string;
}
export interface MOQSettings {
consolidation_window_days: number;
allow_early_ordering: boolean;
enable_batch_optimization: boolean;
min_batch_size: number;
max_batch_size: number;
}
export interface SupplierSelectionSettings {
price_weight: number;
lead_time_weight: number;
quality_weight: number;
reliability_weight: number;
diversification_threshold: number;
max_single_percentage: number;
enable_supplier_score_optimization: boolean;
}
export interface TenantSettings {
id: string;
tenant_id: string;
@@ -94,6 +130,10 @@ export interface TenantSettings {
supplier_settings: SupplierSettings;
pos_settings: POSSettings;
order_settings: OrderSettings;
replenishment_settings: ReplenishmentSettings;
safety_stock_settings: SafetyStockSettings;
moq_settings: MOQSettings;
supplier_selection_settings: SupplierSelectionSettings;
created_at: string;
updated_at: string;
}
@@ -105,6 +145,10 @@ export interface TenantSettingsUpdate {
supplier_settings?: Partial<SupplierSettings>;
pos_settings?: Partial<POSSettings>;
order_settings?: Partial<OrderSettings>;
replenishment_settings?: Partial<ReplenishmentSettings>;
safety_stock_settings?: Partial<SafetyStockSettings>;
moq_settings?: Partial<MOQSettings>;
supplier_selection_settings?: Partial<SupplierSelectionSettings>;
}
export type SettingsCategory =
@@ -113,7 +157,11 @@ export type SettingsCategory =
| 'production'
| 'supplier'
| 'pos'
| 'order';
| 'order'
| 'replenishment'
| 'safety_stock'
| 'moq'
| 'supplier_selection';
export interface CategoryResetResponse {
category: string;

View File

@@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card } from '../../ui';
import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
import { useToast } from '../../../hooks/ui/useToast';
import { showToast } from '../../../utils/toast';
interface LoginFormProps {
onSuccess?: () => void;
@@ -38,7 +38,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({
const { login } = useAuthActions();
const isLoading = useAuthLoading();
const error = useAuthError();
const { success, error: showError } = useToast();
// Auto-focus on email field when component mounts
useEffect(() => {
@@ -78,7 +78,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({
try {
await login(credentials.email, credentials.password);
success('¡Bienvenido de vuelta a tu panadería!', {
showToast.success('¡Bienvenido de vuelta a tu panadería!', {
title: 'Sesión iniciada correctamente'
});
onSuccess?.();

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { Button, Input, Card } from '../../ui';
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
import { useAuthActions } from '../../../stores/auth.store';
import { useToast } from '../../../hooks/ui/useToast';
import { showToast } from '../../../utils/toast';
import { useResetPassword } from '../../../api/hooks/auth';
interface PasswordResetFormProps {
@@ -39,7 +39,7 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
const { mutateAsync: resetPasswordMutation, isPending: isResetting } = useResetPassword();
const isLoading = isResetting;
const error = null;
const { showToast } = useToast();
const isResetMode = Boolean(token) || mode === 'reset';
@@ -62,11 +62,9 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
setIsTokenValid(isValidFormat);
if (!isValidFormat) {
showToast({
type: 'error',
title: 'Token inválido',
message: 'El enlace de restablecimiento no es válido o ha expirado'
});
showToast.error('El enlace de restablecimiento no es válido o ha expirado', {
title: 'Token inválido'
});
}
}
}, [token, showToast]);
@@ -154,16 +152,12 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
// Note: Password reset request functionality needs to be implemented in backend
// For now, show a message that the feature is coming soon
setIsEmailSent(true);
showToast({
type: 'info',
title: 'Función en desarrollo',
message: 'La solicitud de restablecimiento de contraseña estará disponible próximamente. Por favor, contacta al administrador.'
showToast.info('La solicitud de restablecimiento de contraseña estará disponible próximamente. Por favor, contacta al administrador.', {
title: 'Función en desarrollo'
});
} catch (err) {
showToast({
type: 'error',
title: 'Error de conexión',
message: 'No se pudo conectar con el servidor. Verifica tu conexión a internet.'
showToast.error('No se pudo conectar con el servidor. Verifica tu conexión a internet.', {
title: 'Error de conexión'
});
}
};
@@ -180,10 +174,8 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
}
if (!token || isTokenValid === false) {
showToast({
type: 'error',
title: 'Token inválido',
message: 'El enlace de restablecimiento no es válido. Solicita uno nuevo.'
showToast.error('El enlace de restablecimiento no es válido. Solicita uno nuevo.', {
title: 'Token inválido'
});
return;
}
@@ -195,18 +187,14 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
new_password: password
});
showToast({
type: 'success',
title: 'Contraseña actualizada',
message: '¡Tu contraseña ha sido restablecida exitosamente! Ya puedes iniciar sesión.'
showToast.success('¡Tu contraseña ha sido restablecida exitosamente! Ya puedes iniciar sesión.', {
title: 'Contraseña actualizada'
});
onSuccess?.();
} catch (err: any) {
const errorMessage = err?.response?.data?.detail || err?.message || 'El enlace ha expirado o no es válido. Solicita un nuevo restablecimiento.';
showToast({
type: 'error',
title: 'Error al restablecer contraseña',
message: errorMessage
showToast.error(errorMessage, {
title: 'Error al restablecer contraseña'
});
}
};
@@ -599,4 +587,4 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
);
};
export default PasswordResetForm;
export default PasswordResetForm;

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button, Input, Card, Select, Avatar, Modal } from '../../ui';
import { useAuthUser } from '../../../stores/auth.store';
import { useToast } from '../../../hooks/ui/useToast';
import { showToast } from '../../../utils/toast';
import { useUpdateProfile, useChangePassword, useAuthProfile } from '../../../api/hooks/auth';
interface ProfileSettingsProps {
@@ -42,7 +42,7 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
initialTab = 'profile'
}) => {
const user = useAuthUser();
const { showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -139,20 +139,16 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
// Validate file type
if (!file.type.startsWith('image/')) {
showToast({
type: 'error',
title: 'Archivo inválido',
message: 'Por favor, selecciona una imagen válida'
showToast.error('Solo se permiten archivos de imagen (JPEG, PNG, GIF, WEBP)', {
title: 'Error'
});
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
showToast({
type: 'error',
title: 'Archivo muy grande',
message: 'La imagen debe ser menor a 5MB'
showToast.error('El archivo es demasiado grande. Máximo 5MB permitido', {
title: 'Error'
});
return;
}
@@ -174,16 +170,12 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
setProfileData(prev => ({ ...prev, avatar: newImageUrl }));
setHasChanges(prev => ({ ...prev, profile: true }));
showToast({
type: 'success',
title: 'Imagen subida',
message: 'Tu foto de perfil ha sido actualizada'
showToast.success('¡Éxito!', {
title: 'Foto de perfil actualizada correctamente'
});
} catch (error) {
showToast({
type: 'error',
title: 'Error al subir imagen',
message: 'No se pudo subir la imagen. Intenta de nuevo.'
showToast.error('No se pudo actualizar la foto de perfil', {
title: 'Error'
});
} finally {
setUploadingImage(false);
@@ -283,17 +275,13 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
});
setHasChanges(false);
showToast({
type: 'success',
title: 'Perfil actualizado',
message: 'Tu información ha sido guardada correctamente'
showToast.success('¡Éxito!', {
title: 'Perfil actualizado correctamente'
});
onSuccess?.();
} catch (err) {
showToast({
type: 'error',
title: 'Error al actualizar',
message: 'No se pudo actualizar tu perfil'
showToast.error('No se pudo actualizar el perfil', {
title: 'Error'
});
}
};
@@ -311,10 +299,8 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
new_password: passwordData.newPassword,
});
showToast({
type: 'success',
title: 'Contraseña actualizada',
message: 'Tu contraseña ha sido cambiada correctamente'
showToast.success('¡Éxito!', {
title: 'Contraseña cambiada correctamente'
});
setPasswordData({
@@ -323,10 +309,8 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
confirmNewPassword: ''
});
} catch (error) {
showToast({
type: 'error',
title: 'Error al cambiar contraseña',
message: 'No se pudo cambiar tu contraseña. Por favor, verifica tu contraseña actual.'
showToast.error('No se pudo cambiar la contraseña', {
title: 'Error'
});
}
};
@@ -725,4 +709,4 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
);
};
export default ProfileSettings;
export default ProfileSettings;

View File

@@ -4,7 +4,7 @@ import { useSearchParams } from 'react-router-dom';
import { Button, Input, Card } from '../../ui';
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
import { useToast } from '../../../hooks/ui/useToast';
import { showToast } from '../../../utils/toast';
import { SubscriptionPricingCards } from '../../subscription/SubscriptionPricingCards';
import PaymentForm from './PaymentForm';
import { loadStripe } from '@stripe/stripe-js';
@@ -68,7 +68,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
const { register } = useAuthActions();
const isLoading = useAuthLoading();
const error = useAuthError();
const { success: showSuccessToast, error: showErrorToast } = useToast();
// Detect pilot program participation
const { isPilot, couponCode, trialMonths } = usePilotDetection();
@@ -236,12 +236,12 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.'
: '¡Bienvenido! Tu cuenta ha sido creada correctamente.';
showSuccessToast(t('auth:register.registering', successMessage), {
showToast.success(t('auth:register.registering', successMessage), {
title: t('auth:alerts.success_create', 'Cuenta creada exitosamente')
});
onSuccess?.();
} catch (err) {
showErrorToast(error || t('auth:register.register_button', 'No se pudo crear la cuenta. Verifica que el email no esté en uso.'), {
showToast.error(error || t('auth:register.register_button', 'No se pudo crear la cuenta. Verifica que el email no esté en uso.'), {
title: t('auth:alerts.error_create', 'Error al crear la cuenta')
});
}
@@ -252,7 +252,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
};
const handlePaymentError = (errorMessage: string) => {
showErrorToast(errorMessage, {
showToast.error(errorMessage, {
title: 'Error en el pago'
});
};

View File

@@ -3,7 +3,7 @@ import { Zap, Key, Settings as SettingsIcon, RefreshCw } from 'lucide-react';
import { AddModal, AddModalSection, AddModalField } from '../../ui/AddModal/AddModal';
import { posService } from '../../../api/services/pos';
import { POSProviderConfig, POSSystem, POSEnvironment } from '../../../api/types/pos';
import { useToast } from '../../../hooks/ui/useToast';
import { showToast } from '../../../utils/toast';
import { statusColors } from '../../../styles/colors';
interface CreatePOSConfigModalProps {
@@ -29,7 +29,7 @@ export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
}) => {
const [loading, setLoading] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<POSSystem | ''>('');
const { addToast } = useToast();
// Initialize selectedProvider in edit mode
React.useEffect(() => {
@@ -250,7 +250,7 @@ export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
// Find selected provider
const provider = supportedProviders.find(p => p.id === formData.provider);
if (!provider) {
addToast('Por favor selecciona un sistema POS', { type: 'error' });
showToast.error('Por favor selecciona un sistema POS');
return;
}
@@ -298,17 +298,17 @@ export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
...payload,
config_id: existingConfig.id
});
addToast('Configuración actualizada correctamente', { type: 'success' });
showToast.success('Configuración actualizada correctamente');
} else {
await posService.createPOSConfiguration(payload);
addToast('Configuración creada correctamente', { type: 'success' });
showToast.success('Configuración creada correctamente');
}
onSuccess?.();
onClose();
} catch (error: any) {
console.error('Error saving POS configuration:', error);
addToast(error?.message || 'Error al guardar la configuración', { type: 'error' });
showToast.error(error?.message || 'Error al guardar la configuración');
throw error; // Let AddModal handle error state
} finally {
setLoading(false);
@@ -345,7 +345,7 @@ export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
// Custom validation if needed
if (errors && Object.keys(errors).length > 0) {
const firstError = Object.values(errors)[0];
addToast(firstError, { type: 'error' });
showToast.error(firstError);
}
}}
onFieldChange={handleFieldChange}

View File

@@ -396,6 +396,14 @@ export const Footer = forwardRef<FooterRef, FooterProps>(({
)}
</div>
{/* Made with love in Madrid */}
{!compact && (
<div className="flex items-center gap-2 text-[var(--text-tertiary)]">
<Heart className="w-4 h-4 text-red-500 fill-red-500" />
<span>{t('common:footer.made_with_love', 'Hecho con amor en Madrid')}</span>
</div>
)}
{/* Essential utilities only */}
<div className="flex items-center gap-4">
{/* Privacy links - minimal set */}

View File

@@ -168,12 +168,6 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []);
const { filteredRoutes: subscriptionFilteredRoutes } = useSubscriptionAwareRoutes(baseNavigationRoutes);
// Force re-render when subscription changes
useEffect(() => {
// The subscriptionVersion change will trigger a re-render
// This ensures the sidebar picks up new route filtering based on updated subscription
}, [subscriptionVersion]);
// Map route paths to translation keys
const getTranslationKey = (routePath: string): string => {
const pathMappings: Record<string, string> = {

View File

@@ -0,0 +1,46 @@
import React from 'react';
export interface SliderProps {
min: number;
max: number;
step?: number;
value: number[];
onValueChange: (value: number[]) => void;
disabled?: boolean;
className?: string;
}
const Slider: React.FC<SliderProps> = ({
min,
max,
step = 1,
value,
onValueChange,
disabled = false,
className = '',
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = parseFloat(e.target.value);
onValueChange([newValue]);
};
return (
<div className={`flex items-center space-x-4 ${className}`}>
<input
type="range"
min={min}
max={max}
step={step}
value={value[0]}
onChange={handleChange}
disabled={disabled}
className="w-full h-2 bg-[var(--bg-secondary)] rounded-lg appearance-none cursor-pointer accent-[var(--color-primary)] disabled:opacity-50 disabled:cursor-not-allowed"
/>
<span className="text-sm text-[var(--text-secondary)] min-w-12">
{(value[0] * 100).toFixed(0)}%
</span>
</div>
);
};
export default Slider;

View File

@@ -0,0 +1,3 @@
export { default } from './Slider';
export { default as Slider } from './Slider';
export type { SliderProps } from './Slider';

View File

@@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { useTenant } from '../../stores/tenant.store';
import { useToast } from '../../hooks/ui/useToast';
import { showToast } from '../../utils/toast';
import { ChevronDown, Building2, Check, AlertCircle, Plus, X } from 'lucide-react';
interface TenantSwitcherProps {
@@ -36,7 +36,7 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
clearError,
} = useTenant();
const { success: showSuccessToast, error: showErrorToast } = useToast();
// Load tenants on mount
useEffect(() => {
@@ -150,11 +150,11 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
if (success) {
const newTenant = availableTenants?.find(t => t.id === tenantId);
showSuccessToast(`Switched to ${newTenant?.name}`, {
showToast.success(`Switched to ${newTenant?.name}`, {
title: 'Tenant Switched'
});
} else {
showErrorToast(error || 'Failed to switch tenant', {
showToast.error(error || 'Failed to switch tenant', {
title: 'Switch Failed'
});
}

View File

@@ -1,7 +1,7 @@
import React, { createContext, useContext, useEffect, useRef, useState, ReactNode } from 'react';
import { useAuthStore } from '../stores/auth.store';
import { useUIStore } from '../stores/ui.store';
import { useCurrentTenant } from '../stores/tenant.store';
import { showToast } from '../utils/toast';
interface SSEEvent {
type: string;
@@ -41,7 +41,6 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
const reconnectAttempts = useRef(0);
const { isAuthenticated, token } = useAuthStore();
const { showToast } = useUIStore();
const currentTenant = useCurrentTenant();
const connect = () => {
@@ -137,12 +136,7 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
toastType = 'info';
}
showToast({
type: toastType,
title: data.title || 'Notificación',
message: data.message,
duration: data.severity === 'urgent' ? 0 : 5000,
});
showToast[toastType](data.message, { title: data.title || 'Notificación', duration: data.severity === 'urgent' ? 0 : 5000 });
}
// Trigger registered listeners
@@ -200,12 +194,7 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
else if (data.severity === 'medium') toastType = 'warning';
else toastType = 'info';
showToast({
type: toastType,
title: data.title || 'Alerta',
message: data.message,
duration: data.severity === 'urgent' ? 0 : 5000,
});
showToast[toastType](data.message, { title: data.title || 'Alerta', duration: data.severity === 'urgent' ? 0 : 5000 });
// Trigger listeners
const listeners = eventListenersRef.current.get('alert');
@@ -230,12 +219,7 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
setLastEvent(sseEvent);
// Show recommendation toast
showToast({
type: 'info',
title: data.title || 'Recomendación',
message: data.message,
duration: 5000,
});
showToast.info(data.message, { title: data.title || 'Recomendación', duration: 5000 });
// Trigger listeners
const listeners = eventListenersRef.current.get('recommendation');
@@ -262,12 +246,7 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
// Show urgent alert toast
const toastType = data.severity === 'urgent' ? 'error' : 'error';
showToast({
type: toastType,
title: data.title || 'Alerta de Inventario',
message: data.message,
duration: data.severity === 'urgent' ? 0 : 5000,
});
showToast[toastType](data.message, { title: data.title || 'Alerta de Inventario', duration: data.severity === 'urgent' ? 0 : 5000 });
// Trigger alert listeners
const listeners = eventListenersRef.current.get('alert');
@@ -297,12 +276,7 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
else if (data.severity === 'high') toastType = 'warning';
else if (data.severity === 'medium') toastType = 'info';
showToast({
type: toastType,
title: data.title || 'Notificación',
message: data.message,
duration: data.severity === 'urgent' ? 0 : 5000,
});
showToast[toastType](data.message, { title: data.title || 'Notificación', duration: data.severity === 'urgent' ? 0 : 5000 });
// Trigger listeners for both notification and specific type
const notificationListeners = eventListenersRef.current.get('notification');

View File

@@ -4,8 +4,8 @@ export const getDemoTourSteps = (): DriveStep[] => [
{
element: '[data-tour="demo-banner"]',
popover: {
title: '¡Bienvenido a BakeryIA Demo!',
description: 'Estás en una sesión demo de 30 minutos con datos reales de una panadería española. Te guiaremos por las funciones principales de la plataforma. Puedes cerrar el tour en cualquier momento con ESC.',
title: '¡Bienvenido a BakeryIA!',
description: 'Descubre cómo gestionar tu panadería en 5 minutos al día. Esta demo de 30 minutos usa datos reales de una panadería española. Te mostramos cómo ahorrar 2-3 horas diarias en planificación y reducir desperdicio un 15-25%. Puedes cerrar el tour con ESC.',
side: 'bottom',
align: 'center',
},
@@ -13,8 +13,8 @@ export const getDemoTourSteps = (): DriveStep[] => [
{
element: '[data-tour="dashboard-stats"]',
popover: {
title: 'Métricas en Tiempo Real',
description: 'Aquí ves las métricas clave de tu panadería actualizadas al instante: ventas del día, pedidos pendientes, productos vendidos y alertas de stock crítico.',
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.',
side: 'bottom',
align: 'start',
},
@@ -22,26 +22,26 @@ export const getDemoTourSteps = (): DriveStep[] => [
{
element: '[data-tour="real-time-alerts"]',
popover: {
title: 'Alertas Inteligentes',
description: 'El sistema te avisa automáticamente de stock bajo, pedidos urgentes, predicciones de demanda y oportunidades de producción. Toda la información importante en un solo lugar.',
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="procurement-plans"]',
element: '[data-tour="pending-po-approvals"]',
popover: {
title: 'Planes de Aprovisionamiento',
description: 'Visualiza qué ingredientes necesitas comprar hoy según tus planes de producción. El sistema calcula automáticamente las cantidades necesarias.',
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.',
side: 'top',
align: 'start',
},
},
{
element: '[data-tour="production-plans"]',
element: '[data-tour="today-production"]',
popover: {
title: 'Gestión de Producción',
description: 'Consulta y gestiona tus órdenes de producción programadas. Puedes ver el estado de cada orden, los ingredientes necesarios y el tiempo estimado.',
title: 'Qué Producir Hoy (Ya Planificado)',
description: 'El sistema programa automáticamente tus lotes de producción cada mañana basándose en la demanda prevista. Puedes ver qué hacer, cuándo hacerlo, qué ingredientes necesitas y el tiempo estimado. Solo tienes que empezar a producir.',
side: 'top',
align: 'start',
},
@@ -49,8 +49,8 @@ export const getDemoTourSteps = (): DriveStep[] => [
{
element: '[data-tour="sidebar-database"]',
popover: {
title: 'Base de Datos de tu Panadería',
description: 'Accede a toda la información de tu negocio: inventario de ingredientes, recetas, proveedores, equipos y equipo de trabajo.',
title: 'Tu Información en un Solo Lugar',
description: 'Toda la información de tu panadería centralizada: ingredientes, recetas, proveedores, equipos y trabajadores. Sin papeles, sin Excel dispersos. Todo en un solo lugar, siempre actualizado.',
side: 'right',
align: 'start',
},
@@ -58,8 +58,8 @@ export const getDemoTourSteps = (): DriveStep[] => [
{
element: '[data-tour="sidebar-operations"]',
popover: {
title: 'Operaciones Diarias',
description: 'Gestiona las operaciones del día a día: aprovisionamiento de ingredientes, producción de recetas y punto de venta (POS) para registrar ventas.',
title: 'Lo Que Hay Que Hacer Hoy',
description: 'Aquí gestionas el día a día: revisar y aprobar compras, iniciar producción y registrar ventas. Simple y directo. El sistema ya hizo la planificación compleja por ti.',
side: 'right',
align: 'start',
},
@@ -67,8 +67,8 @@ export const getDemoTourSteps = (): DriveStep[] => [
{
element: '[data-tour="sidebar-analytics"]',
popover: {
title: 'Análisis e Inteligencia Artificial',
description: 'Accede a análisis avanzados de ventas, producción y pronósticos de demanda con IA. Simula escenarios y obtén insights inteligentes para tu negocio.',
title: 'Entiende Tu Negocio con Gráficos Simples',
description: 'Análisis de ventas, producción y pronósticos de demanda en gráficos fáciles de entender. Simula escenarios (¿qué pasa si subo precios?) y recibe recomendaciones de la IA. No necesitas ser experto en datos.',
side: 'right',
align: 'start',
},
@@ -76,8 +76,8 @@ export const getDemoTourSteps = (): DriveStep[] => [
{
element: '[data-tour="header-tenant-selector"]',
popover: {
title: 'Multi-Panadería',
description: 'Si gestionas varias panaderías o puntos de venta, puedes cambiar entre ellas fácilmente desde aquí. Cada panadería tiene sus propios datos aislados.',
title: 'Gestiona Varias Panaderías',
description: 'Si tienes múltiples puntos de venta, cambia entre ellos fácilmente aquí. Cada panadería tiene sus propios datos completamente separados para mayor seguridad y claridad.',
side: 'bottom',
align: 'end',
},
@@ -86,15 +86,15 @@ export const getDemoTourSteps = (): DriveStep[] => [
element: '[data-tour="demo-banner-actions"]',
popover: {
title: 'Limitaciones del Demo',
description: 'En modo demo puedes explorar todas las funciones, pero algunas acciones destructivas están deshabilitadas. Los cambios que hagas no se guardarán después de que expire la sesión.',
description: 'En modo demo puedes explorar todas las funciones, pero no puedes hacer cambios permanentes. Los datos que veas son reales pero tus modificaciones no afectarán nada. Perfecto para aprender sin riesgo.',
side: 'bottom',
align: 'center',
},
},
{
popover: {
title: '¿Listo para gestionar tu panadería real?',
description: 'Has explorado las funcionalidades principales de BakeryIA. Crea una cuenta gratuita para acceder a todas las funciones sin límites, guardar tus datos de forma permanente y conectar tu negocio real.',
title: '¿Listo Para Tu Panadería Real?',
description: 'Ahora que has visto cómo funciona, imagina ahorrando 2-3 horas diarias y reduciendo desperdicio entre €500-1500 al mes. Crea una cuenta gratuita para conectar tu panadería real, guardar tus datos de forma permanente y empezar a ahorrar desde mañana.',
side: 'top',
align: 'center',
},
@@ -106,7 +106,7 @@ export const getMobileTourSteps = (): DriveStep[] => [
element: '[data-tour="demo-banner"]',
popover: {
title: '¡Bienvenido a BakeryIA!',
description: 'Sesión demo de 30 minutos con datos reales. Te mostraremos las funciones clave.',
description: 'Gestiona tu panadería en 5 min/día. Demo de 30 min con datos reales. Ahorra 2-3h diarias y reduce desperdicio 15-25%.',
side: 'bottom',
align: 'center',
},
@@ -114,8 +114,8 @@ export const getMobileTourSteps = (): DriveStep[] => [
{
element: '[data-tour="dashboard-stats"]',
popover: {
title: 'Métricas en Tiempo Real',
description: 'Ventas, pedidos, productos y alertas actualizadas al instante.',
title: 'Tu Panel de Control',
description: 'Todo lo importante en un vistazo. Empieza tu día aquí en 30 segundos.',
side: 'bottom',
align: 'start',
},
@@ -123,26 +123,26 @@ export const getMobileTourSteps = (): DriveStep[] => [
{
element: '[data-tour="real-time-alerts"]',
popover: {
title: 'Alertas Inteligentes',
description: 'Stock bajo, pedidos urgentes y predicciones de demanda en un solo lugar.',
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="procurement-plans"]',
element: '[data-tour="pending-po-approvals"]',
popover: {
title: 'Aprovisionamiento',
description: 'Ingredientes que necesitas comprar hoy calculados automáticamente.',
title: 'Qué Comprar (Ya Calculado)',
description: 'Cada mañana el sistema calcula qué ingredientes comprar. Solo aprueba con un clic. Adiós Excel.',
side: 'top',
align: 'start',
},
},
{
element: '[data-tour="production-plans"]',
element: '[data-tour="today-production"]',
popover: {
title: 'Producción',
description: 'Gestiona órdenes de producción y consulta ingredientes necesarios.',
title: 'Qué Producir (Ya Planificado)',
description: 'El sistema programa tu producción automáticamente cada mañana. Solo tienes que empezar a producir.',
side: 'top',
align: 'start',
},
@@ -150,8 +150,8 @@ export const getMobileTourSteps = (): DriveStep[] => [
{
element: '[data-tour="sidebar-menu-toggle"]',
popover: {
title: 'Menú de Navegación',
description: 'Toca aquí para acceder a Base de Datos, Operaciones y Análisis.',
title: 'Menú: Tu Información y Operaciones',
description: 'Aquí accedes a tu información (recetas, ingredientes, proveedores) y operaciones diarias.',
side: 'bottom',
align: 'start',
},
@@ -160,15 +160,15 @@ export const getMobileTourSteps = (): DriveStep[] => [
element: '[data-tour="demo-banner-actions"]',
popover: {
title: 'Limitaciones del Demo',
description: 'Puedes explorar todo, pero los cambios no se guardan permanentemente.',
description: 'Explora todo sin riesgo. Los cambios no afectan nada. Perfecto para aprender.',
side: 'bottom',
align: 'center',
},
},
{
popover: {
title: '¿Listo para tu panadería real?',
description: 'Crea una cuenta gratuita para acceso completo sin límites y datos permanentes.',
title: '¿Listo Para Tu Panadería?',
description: 'Ahorra 2-3h diarias y €500-1500/mes. Crea cuenta gratuita para empezar desde mañana.',
side: 'top',
align: 'center',
},

View File

@@ -1,9 +1,10 @@
import { useState, useCallback, useEffect } from 'react';
import { driver, Driver } from 'driver.js';
import { useNavigate } from 'react-router-dom';
import { ROUTES } from '../../../router/routes.config';
import { getDriverConfig } from '../config/driver-config';
import { getDemoTourSteps, getMobileTourSteps } from '../config/tour-steps';
import { getTourState, saveTourState, clearTourState, clearTourStartPending } from '../utils/tour-state';
import { getTourState, saveTourState, clearTourStartPending, clearTourState } from '../utils/tour-state';
import { trackTourEvent } from '../utils/tour-analytics';
import '../styles.css';
@@ -73,19 +74,35 @@ export const useDemoTour = () => {
const startTour = useCallback((fromStep: number = 0) => {
console.log('[useDemoTour] startTour called with fromStep:', fromStep);
// Check if we're already on the dashboard
const currentPath = window.location.pathname;
if (currentPath !== ROUTES.DASHBOARD) {
console.log('[useDemoTour] Not on dashboard, navigating to:', ROUTES.DASHBOARD);
// 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;
}
const steps = isMobile ? getMobileTourSteps() : getDemoTourSteps();
console.log('[useDemoTour] Using', isMobile ? 'mobile' : 'desktop', 'steps, total:', steps.length);
// Check if first element exists
const firstElement = steps[0]?.element;
if (firstElement) {
const el = document.querySelector(firstElement);
console.log('[useDemoTour] First element exists:', !!el, 'selector:', firstElement);
if (!el) {
console.warn('[useDemoTour] First tour element not found in DOM! Delaying tour start...');
// Retry after DOM is ready
setTimeout(() => startTour(fromStep), 500);
return;
// Check if first element exists (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);
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);
return;
}
}
}
@@ -132,7 +149,7 @@ export const useDemoTour = () => {
});
clearTourStartPending();
}, [isMobile, handleTourDestroy, handleStepComplete, handleTourComplete]);
}, [isMobile, handleTourDestroy, handleStepComplete, handleTourComplete, navigate]);
const resumeTour = useCallback(() => {
const state = getTourState();

View File

@@ -44,20 +44,50 @@
border-radius: 8px;
font-weight: 600;
font-size: 0.9375rem;
line-height: 1.5;
transition: all 0.2s ease;
border: none;
cursor: pointer;
/* Ensure crisp text rendering */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
/* Prevent blur from transforms */
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
perspective: 1000px;
/* Ensure no opacity issues */
opacity: 1;
/* Force hardware acceleration for crisp rendering */
transform: translate3d(0, 0, 0);
will-change: transform;
/* Additional text clarity */
text-shadow: none;
filter: none;
}
.driver-popover.bakery-tour-popover .driver-popover-next-btn {
background: var(--color-primary);
color: white;
color: #ffffff;
flex: 1;
/* Ensure text is fully opaque */
opacity: 1;
/* WHITE TEXT ON COLORED BG FIX: Use antialiased, not subpixel */
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
/* Slight letter spacing helps with clarity */
letter-spacing: 0.01em;
/* Prevent any blur from transforms */
transform: translate3d(0, 0, 0);
/* NO text-shadow - can cause blur */
text-shadow: none;
/* Ensure proper line height */
line-height: 1.5;
}
.driver-popover.bakery-tour-popover .driver-popover-next-btn:hover {
background: var(--color-primary-dark);
transform: translateY(-1px);
transform: translate3d(0, -1px, 0);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
@@ -65,11 +95,21 @@
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-default);
/* Ensure text is fully opaque */
opacity: 1;
/* Same crisp text rendering as next button */
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
letter-spacing: 0.01em;
transform: translate3d(0, 0, 0);
text-shadow: none;
line-height: 1.5;
}
.driver-popover.bakery-tour-popover .driver-popover-prev-btn:hover {
background: var(--bg-tertiary);
border-color: var(--border-hover);
transform: translate3d(0, 0, 0);
}
.driver-popover.bakery-tour-popover .driver-popover-close-btn {

View File

@@ -1,182 +0,0 @@
/**
* Toast hook for managing toast notifications
*/
import { useState, useCallback, useEffect } from 'react';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export type ToastPosition = 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';
export interface Toast {
id: string;
type: ToastType;
title?: string;
message: string;
duration?: number;
dismissible?: boolean;
action?: {
label: string;
onClick: () => void;
};
timestamp: number;
}
interface ToastState {
toasts: Toast[];
position: ToastPosition;
maxToasts: number;
}
interface ToastOptions {
type?: ToastType;
title?: string;
duration?: number;
dismissible?: boolean;
action?: {
label: string;
onClick: () => void;
};
}
interface ToastActions {
addToast: (message: string, options?: ToastOptions) => string;
removeToast: (id: string) => void;
clearToasts: () => void;
success: (message: string, options?: Omit<ToastOptions, 'type'>) => string;
error: (message: string, options?: Omit<ToastOptions, 'type'>) => string;
warning: (message: string, options?: Omit<ToastOptions, 'type'>) => string;
info: (message: string, options?: Omit<ToastOptions, 'type'>) => string;
setPosition: (position: ToastPosition) => void;
setMaxToasts: (max: number) => void;
}
const DEFAULT_DURATION = 5000; // 5 seconds
const DEFAULT_POSITION: ToastPosition = 'top-right';
const DEFAULT_MAX_TOASTS = 6;
// Generate unique ID
const generateId = (): string => {
return `toast_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
export const useToast = (
initialPosition: ToastPosition = DEFAULT_POSITION,
initialMaxToasts: number = DEFAULT_MAX_TOASTS
): ToastState & ToastActions => {
const [state, setState] = useState<ToastState>({
toasts: [],
position: initialPosition,
maxToasts: initialMaxToasts,
});
// Remove toast by ID
const removeToast = useCallback((id: string) => {
setState(prev => ({
...prev,
toasts: prev.toasts.filter(toast => toast.id !== id),
}));
}, []);
// Add toast
const addToast = useCallback((message: string, options: ToastOptions = {}): string => {
const id = generateId();
const toast: Toast = {
id,
type: options.type || 'info',
title: options.title,
message,
duration: options.duration ?? DEFAULT_DURATION,
dismissible: options.dismissible ?? true,
action: options.action,
timestamp: Date.now(),
};
setState(prev => {
const newToasts = [...prev.toasts, toast];
// Limit number of toasts
if (newToasts.length > prev.maxToasts) {
return {
...prev,
toasts: newToasts.slice(-prev.maxToasts),
};
}
return {
...prev,
toasts: newToasts,
};
});
// Auto-dismiss toast if duration is set
if (toast.duration && toast.duration > 0) {
setTimeout(() => {
removeToast(id);
}, toast.duration);
}
return id;
}, [removeToast]);
// Clear all toasts
const clearToasts = useCallback(() => {
setState(prev => ({
...prev,
toasts: [],
}));
}, []);
// Convenience methods for different toast types
const success = useCallback((message: string, options: Omit<ToastOptions, 'type'> = {}) => {
return addToast(message, { ...options, type: 'success' });
}, [addToast]);
const error = useCallback((message: string, options: Omit<ToastOptions, 'type'> = {}) => {
return addToast(message, { ...options, type: 'error', duration: options.duration ?? 8000 });
}, [addToast]);
const warning = useCallback((message: string, options: Omit<ToastOptions, 'type'> = {}) => {
return addToast(message, { ...options, type: 'warning' });
}, [addToast]);
const info = useCallback((message: string, options: Omit<ToastOptions, 'type'> = {}) => {
return addToast(message, { ...options, type: 'info' });
}, [addToast]);
// Set toast position
const setPosition = useCallback((position: ToastPosition) => {
setState(prev => ({
...prev,
position,
}));
}, []);
// Set maximum number of toasts
const setMaxToasts = useCallback((maxToasts: number) => {
setState(prev => {
const newToasts = prev.toasts.length > maxToasts
? prev.toasts.slice(-maxToasts)
: prev.toasts;
return {
...prev,
maxToasts,
toasts: newToasts,
};
});
}, []);
return {
...state,
addToast,
removeToast,
clearToasts,
success,
error,
warning,
info,
setPosition,
setMaxToasts,
};
};

View File

@@ -342,7 +342,8 @@
"twitter": "Twitter",
"linkedin": "LinkedIn",
"github": "GitHub"
}
},
"made_with_love": "Made with love in Madrid"
},
"breadcrumbs": {
"home": "Home",

View File

@@ -366,7 +366,8 @@
"twitter": "Twitter",
"linkedin": "LinkedIn",
"github": "GitHub"
}
},
"made_with_love": "Hecho con amor en Madrid"
},
"breadcrumbs": {
"home": "Inicio",

View File

@@ -342,7 +342,8 @@
"twitter": "Twitter",
"linkedin": "LinkedIn",
"github": "GitHub"
}
},
"made_with_love": "Madrilen maitasunez eginda"
},
"breadcrumbs": {
"home": "Hasiera",

View File

@@ -13,10 +13,11 @@ import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../featu
import { useDashboardStats } from '../../api/hooks/dashboard';
import { usePurchaseOrder, useApprovePurchaseOrder, useRejectPurchaseOrder } from '../../api/hooks/purchase-orders';
import { useBatchDetails, useUpdateBatchStatus } from '../../api/hooks/production';
import { useRunDailyWorkflow } from '../../api';
import { ProductionStatusEnum } from '../../api';
import {
AlertTriangle,
Clock,
Clock,
Euro,
Package,
FileText,
@@ -28,9 +29,10 @@ import {
Factory,
Timer,
TrendingDown,
Leaf
Leaf,
Play
} from 'lucide-react';
import toast from 'react-hot-toast';
import { showToast } from '../../utils/toast';
const DashboardPage: React.FC = () => {
const { t } = useTranslation();
@@ -76,18 +78,43 @@ const DashboardPage: React.FC = () => {
const approvePOMutation = useApprovePurchaseOrder();
const rejectPOMutation = useRejectPurchaseOrder();
const updateBatchStatusMutation = useUpdateBatchStatus();
const orchestratorMutation = useRunDailyWorkflow();
const handleRunOrchestrator = async () => {
try {
await orchestratorMutation.mutateAsync(currentTenant?.id || '');
showToast.success('Flujo de planificación ejecutado exitosamente');
} catch (error) {
console.error('Error running orchestrator:', error);
showToast.error('Error al ejecutar flujo de planificación');
}
};
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'));
if (isDemoMode && shouldStartTour()) {
// 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()');
startTour();
clearTourStartPending();
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);
@@ -114,10 +141,10 @@ const DashboardPage: React.FC = () => {
batchId,
statusUpdate: { status: ProductionStatusEnum.IN_PROGRESS }
});
toast.success('Lote iniciado');
showToast.success('Lote iniciado');
} catch (error) {
console.error('Error starting batch:', error);
toast.error('Error al iniciar lote');
showToast.error('Error al iniciar lote');
}
};
@@ -128,10 +155,10 @@ const DashboardPage: React.FC = () => {
batchId,
statusUpdate: { status: ProductionStatusEnum.ON_HOLD }
});
toast.success('Lote pausado');
showToast.success('Lote pausado');
} catch (error) {
console.error('Error pausing batch:', error);
toast.error('Error al pausar lote');
showToast.error('Error al pausar lote');
}
};
@@ -147,10 +174,10 @@ const DashboardPage: React.FC = () => {
poId,
notes: 'Aprobado desde el dashboard'
});
toast.success('Orden aprobada');
showToast.success('Orden aprobada');
} catch (error) {
console.error('Error approving PO:', error);
toast.error('Error al aprobar orden');
showToast.error('Error al aprobar orden');
}
};
@@ -161,10 +188,10 @@ const DashboardPage: React.FC = () => {
poId,
reason: 'Rechazado desde el dashboard'
});
toast.success('Orden rechazada');
showToast.success('Orden rechazada');
} catch (error) {
console.error('Error rejecting PO:', error);
toast.error('Error al rechazar orden');
showToast.error('Error al rechazar orden');
}
};
@@ -355,6 +382,18 @@ const DashboardPage: React.FC = () => {
<PageHeader
title={t('dashboard:title', 'Dashboard')}
description={t('dashboard:subtitle', 'Overview of your bakery operations')}
actions={[
{
id: 'run-orchestrator',
label: orchestratorMutation.isPending ? 'Ejecutando...' : 'Ejecutar Planificación Diaria',
icon: Play,
onClick: handleRunOrchestrator,
variant: 'primary', // Primary button for visibility
size: 'sm',
disabled: orchestratorMutation.isPending,
loading: orchestratorMutation.isPending
}
]}
/>
{/* Critical Metrics using StatsGrid */}
@@ -447,12 +486,12 @@ const DashboardPage: React.FC = () => {
poId: poDetails.id,
notes: 'Aprobado desde el dashboard'
});
toast.success('Orden aprobada');
showToast.success('Orden aprobada');
setShowPOModal(false);
setSelectedPOId(null);
} catch (error) {
console.error('Error approving PO:', error);
toast.error('Error al aprobar orden');
showToast.error('Error al aprobar orden');
}
},
variant: 'primary' as const,
@@ -467,12 +506,12 @@ const DashboardPage: React.FC = () => {
poId: poDetails.id,
reason: 'Rechazado desde el dashboard'
});
toast.success('Orden rechazada');
showToast.success('Orden rechazada');
setShowPOModal(false);
setSelectedPOId(null);
} catch (error) {
console.error('Error rejecting PO:', error);
toast.error('Error al rechazar orden');
showToast.error('Error al rechazar orden');
}
},
variant: 'outline' as const,
@@ -521,12 +560,12 @@ const DashboardPage: React.FC = () => {
batchId: batchDetails.id,
statusUpdate: { status: ProductionStatusEnum.IN_PROGRESS }
});
toast.success('Lote iniciado');
showToast.success('Lote iniciado');
setShowBatchModal(false);
setSelectedBatchId(null);
} catch (error) {
console.error('Error starting batch:', error);
toast.error('Error al iniciar lote');
showToast.error('Error al iniciar lote');
}
},
variant: 'primary' as const,
@@ -542,12 +581,12 @@ const DashboardPage: React.FC = () => {
batchId: batchDetails.id,
statusUpdate: { status: ProductionStatusEnum.ON_HOLD }
});
toast.success('Lote pausado');
showToast.success('Lote pausado');
setShowBatchModal(false);
setSelectedBatchId(null);
} catch (error) {
console.error('Error pausing batch:', error);
toast.error('Error al pausar lote');
showToast.error('Error al pausar lote');
}
},
variant: 'outline' as const,
@@ -561,4 +600,4 @@ const DashboardPage: React.FC = () => {
);
};
export default DashboardPage;
export default DashboardPage;

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { Settings, Save, RotateCcw, AlertCircle, Loader } from 'lucide-react';
import { Button, Card } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { showToast } from '../../../../utils/toast';
import { useSettings, useUpdateSettings } from '../../../../api/hooks/settings';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import type {
@@ -13,6 +13,10 @@ import type {
SupplierSettings,
POSSettings,
OrderSettings,
ReplenishmentSettings,
SafetyStockSettings,
MOQSettings,
SupplierSelectionSettings,
} from '../../../../api/types/settings';
import ProcurementSettingsCard from './cards/ProcurementSettingsCard';
import InventorySettingsCard from './cards/InventorySettingsCard';
@@ -20,9 +24,13 @@ import ProductionSettingsCard from './cards/ProductionSettingsCard';
import SupplierSettingsCard from './cards/SupplierSettingsCard';
import POSSettingsCard from './cards/POSSettingsCard';
import OrderSettingsCard from './cards/OrderSettingsCard';
import ReplenishmentSettingsCard from './cards/ReplenishmentSettingsCard';
import SafetyStockSettingsCard from './cards/SafetyStockSettingsCard';
import MOQSettingsCard from './cards/MOQSettingsCard';
import SupplierSelectionSettingsCard from './cards/SupplierSelectionSettingsCard';
const AjustesPage: React.FC = () => {
const { addToast } = useToast();
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
@@ -52,6 +60,10 @@ const AjustesPage: React.FC = () => {
const [supplierSettings, setSupplierSettings] = useState<SupplierSettings | null>(null);
const [posSettings, setPosSettings] = useState<POSSettings | null>(null);
const [orderSettings, setOrderSettings] = useState<OrderSettings | null>(null);
const [replenishmentSettings, setReplenishmentSettings] = useState<ReplenishmentSettings | null>(null);
const [safetyStockSettings, setSafetyStockSettings] = useState<SafetyStockSettings | null>(null);
const [moqSettings, setMoqSettings] = useState<MOQSettings | null>(null);
const [supplierSelectionSettings, setSupplierSelectionSettings] = useState<SupplierSelectionSettings | null>(null);
// Load settings into local state when data is fetched
React.useEffect(() => {
@@ -62,13 +74,18 @@ const AjustesPage: React.FC = () => {
setSupplierSettings(settings.supplier_settings);
setPosSettings(settings.pos_settings);
setOrderSettings(settings.order_settings);
setReplenishmentSettings(settings.replenishment_settings);
setSafetyStockSettings(settings.safety_stock_settings);
setMoqSettings(settings.moq_settings);
setSupplierSelectionSettings(settings.supplier_selection_settings);
setHasUnsavedChanges(false);
}
}, [settings]);
const handleSaveAll = async () => {
if (!tenantId || !procurementSettings || !inventorySettings || !productionSettings ||
!supplierSettings || !posSettings || !orderSettings) {
!supplierSettings || !posSettings || !orderSettings || !replenishmentSettings ||
!safetyStockSettings || !moqSettings || !supplierSelectionSettings) {
return;
}
@@ -84,14 +101,18 @@ const AjustesPage: React.FC = () => {
supplier_settings: supplierSettings,
pos_settings: posSettings,
order_settings: orderSettings,
replenishment_settings: replenishmentSettings,
safety_stock_settings: safetyStockSettings,
moq_settings: moqSettings,
supplier_selection_settings: supplierSelectionSettings,
},
});
setHasUnsavedChanges(false);
addToast('Ajustes guardados correctamente', { type: 'success' });
showToast.success('Ajustes guardados correctamente');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
addToast(`Error al guardar ajustes: ${errorMessage}`, { type: 'error' });
showToast.error(`Error al guardar ajustes: ${errorMessage}`);
} finally {
setIsSaving(false);
}
@@ -105,6 +126,10 @@ const AjustesPage: React.FC = () => {
setSupplierSettings(settings.supplier_settings);
setPosSettings(settings.pos_settings);
setOrderSettings(settings.order_settings);
setReplenishmentSettings(settings.replenishment_settings);
setSafetyStockSettings(settings.safety_stock_settings);
setMoqSettings(settings.moq_settings);
setSupplierSelectionSettings(settings.supplier_selection_settings);
setHasUnsavedChanges(false);
}
};
@@ -256,6 +281,54 @@ const AjustesPage: React.FC = () => {
disabled={isSaving}
/>
)}
{/* Replenishment Settings */}
{replenishmentSettings && (
<ReplenishmentSettingsCard
settings={replenishmentSettings}
onChange={(newSettings) => {
setReplenishmentSettings(newSettings);
handleCategoryChange('replenishment');
}}
disabled={isSaving}
/>
)}
{/* Safety Stock Settings */}
{safetyStockSettings && (
<SafetyStockSettingsCard
settings={safetyStockSettings}
onChange={(newSettings) => {
setSafetyStockSettings(newSettings);
handleCategoryChange('safety_stock');
}}
disabled={isSaving}
/>
)}
{/* MOQ Settings */}
{moqSettings && (
<MOQSettingsCard
settings={moqSettings}
onChange={(newSettings) => {
setMoqSettings(newSettings);
handleCategoryChange('moq');
}}
disabled={isSaving}
/>
)}
{/* Supplier Selection Settings */}
{supplierSelectionSettings && (
<SupplierSelectionSettingsCard
settings={supplierSelectionSettings}
onChange={(newSettings) => {
setSupplierSelectionSettings(newSettings);
handleCategoryChange('supplier_selection');
}}
disabled={isSaving}
/>
)}
</div>
{/* Floating Save Banner */}

View File

@@ -0,0 +1,126 @@
import React from 'react';
import { Card } from '@components/ui';
import { MOQSettings } from '@services/types/settings';
import { Input } from '@components/ui/Input';
interface MOQSettingsCardProps {
settings: MOQSettings;
onChange: (settings: MOQSettings) => void;
disabled?: boolean;
}
const MOQSettingsCard: React.FC<MOQSettingsCardProps> = ({
settings,
onChange,
disabled = false,
}) => {
const handleNumberChange = (field: keyof MOQSettings, value: string) => {
const numValue = value === '' ? 0 : Number(value);
onChange({
...settings,
[field]: numValue,
});
};
const handleToggleChange = (field: keyof MOQSettings, value: boolean) => {
onChange({
...settings,
[field]: value,
});
};
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-6">
Configuración de MOQ (Cantidad Mínima de Pedido)
</h3>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Consolidation Window Days */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Días de Ventana de Consolidación (1-30)
</label>
<Input
type="number"
min="1"
max="30"
value={settings.consolidation_window_days}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleNumberChange('consolidation_window_days', e.target.value)}
disabled={disabled}
className="w-full"
/>
</div>
{/* Min Batch Size */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Tamaño Mínimo de Lote (0.1-1000)
</label>
<Input
type="number"
min="0.1"
max="1000"
step="0.1"
value={settings.min_batch_size}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleNumberChange('min_batch_size', e.target.value)}
disabled={disabled}
className="w-full"
/>
</div>
{/* Max Batch Size */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Tamaño Máximo de Lote (1-10000)
</label>
<Input
type="number"
min="1"
max="10000"
step="1"
value={settings.max_batch_size}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleNumberChange('max_batch_size', e.target.value)}
disabled={disabled}
className="w-full"
/>
</div>
</div>
{/* Toggle Options */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="allow_early_ordering"
checked={settings.allow_early_ordering}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleToggleChange('allow_early_ordering', e.target.checked)}
disabled={disabled}
className="rounded border-[var(--border-primary)]"
/>
<label htmlFor="allow_early_ordering" className="text-sm text-[var(--text-secondary)]">
Permitir Pedido Anticipado
</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enable_batch_optimization"
checked={settings.enable_batch_optimization}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleToggleChange('enable_batch_optimization', e.target.checked)}
disabled={disabled}
className="rounded border-[var(--border-primary)]"
/>
<label htmlFor="enable_batch_optimization" className="text-sm text-[var(--text-secondary)]">
Habilitar Optimización de Lotes
</label>
</div>
</div>
</div>
</Card>
);
};
export default MOQSettingsCard;

View File

@@ -0,0 +1,158 @@
import React from 'react';
import { Card } from '@components/ui';
import { ReplenishmentSettings } from '@services/types/settings';
import { Slider } from '@components/ui/Slider';
import { Input } from '@components/ui/Input';
interface ReplenishmentSettingsCardProps {
settings: ReplenishmentSettings;
onChange: (settings: ReplenishmentSettings) => void;
disabled?: boolean;
}
const ReplenishmentSettingsCard: React.FC<ReplenishmentSettingsCardProps> = ({
settings,
onChange,
disabled = false,
}) => {
const handleNumberChange = (field: keyof ReplenishmentSettings, value: string) => {
const numValue = value === '' ? 0 : Number(value);
onChange({
...settings,
[field]: numValue,
});
};
const handleToggleChange = (field: keyof ReplenishmentSettings, value: boolean) => {
onChange({
...settings,
[field]: value,
});
};
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-6">
Planeamiento de Reposición
</h3>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Projection Horizon Days */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Días de Proyección (1-30)
</label>
<Input
type="number"
min="1"
max="30"
value={settings.projection_horizon_days}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleNumberChange('projection_horizon_days', e.target.value)}
disabled={disabled}
className="w-full"
/>
</div>
{/* Service Level */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Nivel de Servicio ({(settings.service_level * 100).toFixed(0)}%)
</label>
<Slider
min={0}
max={1}
step={0.01}
value={[settings.service_level]}
onValueChange={([value]: number[]) => handleNumberChange('service_level', value.toString())}
disabled={disabled}
/>
</div>
{/* Buffer Days */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Días de Buffer (0-14)
</label>
<Input
type="number"
min="0"
max="14"
value={settings.buffer_days}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleNumberChange('buffer_days', e.target.value)}
disabled={disabled}
className="w-full"
/>
</div>
{/* Demand Forecast Days */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Días de Previsión de Demanda (1-90)
</label>
<Input
type="number"
min="1"
max="90"
value={settings.demand_forecast_days}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleNumberChange('demand_forecast_days', e.target.value)}
disabled={disabled}
className="w-full"
/>
</div>
{/* Min Order Quantity */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Cantidad Mínima de Pedido (0.1-1000)
</label>
<Input
type="number"
min="0.1"
max="100"
step="0.1"
value={settings.min_order_quantity}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleNumberChange('min_order_quantity', e.target.value)}
disabled={disabled}
className="w-full"
/>
</div>
{/* Max Order Quantity */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Cantidad Máxima de Pedido (1-1000)
</label>
<Input
type="number"
min="1"
max="10000"
step="1"
value={settings.max_order_quantity}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleNumberChange('max_order_quantity', e.target.value)}
disabled={disabled}
className="w-full"
/>
</div>
</div>
{/* Enable Auto Replenishment Toggle */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enable_auto_replenishment"
checked={settings.enable_auto_replenishment}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleToggleChange('enable_auto_replenishment', e.target.checked)}
disabled={disabled}
className="rounded border-[var(--border-primary)]"
/>
<label htmlFor="enable_auto_replenishment" className="text-sm text-[var(--text-secondary)]">
Habilitar Reposición Automática
</label>
</div>
</div>
</Card>
);
};
export default ReplenishmentSettingsCard;

View File

@@ -0,0 +1,130 @@
import React from 'react';
import { Card } from '@components/ui';
import { SafetyStockSettings } from '@services/types/settings';
import { Slider } from '@components/ui/Slider';
import { Input } from '@components/ui/Input';
import { Select } from '@components/ui/Select';
interface SafetyStockSettingsCardProps {
settings: SafetyStockSettings;
onChange: (settings: SafetyStockSettings) => void;
disabled?: boolean;
}
const SafetyStockSettingsCard: React.FC<SafetyStockSettingsCardProps> = ({
settings,
onChange,
disabled = false,
}) => {
const handleNumberChange = (field: keyof SafetyStockSettings, value: string) => {
const numValue = value === '' ? 0 : Number(value);
onChange({
...settings,
[field]: numValue,
});
};
const handleStringChange = (field: keyof SafetyStockSettings, value: any) => {
const stringValue = typeof value === 'object' && value !== null ? value[0] : value;
onChange({
...settings,
[field]: stringValue.toString(),
});
};
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-6">
Configuración de Stock de Seguridad
</h3>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Service Level */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Nivel de Servicio ({(settings.service_level * 100).toFixed(0)}%)
</label>
<Slider
min={0}
max={1}
step={0.01}
value={[settings.service_level]}
onValueChange={([value]: number[]) => handleNumberChange('service_level', value.toString())}
disabled={disabled}
/>
</div>
{/* Method */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Método de Cálculo
</label>
<Select
value={settings.method}
onChange={(value) => handleStringChange('method', value)}
disabled={disabled}
options={[
{ value: 'statistical', label: 'Estadístico (Z×σ×√L)' },
{ value: 'fixed_percentage', label: 'Porcentaje Fijo (20%)' },
]}
/>
</div>
{/* Min Safety Stock */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Stock de Seguridad Mínimo (0-1000)
</label>
<Input
type="number"
min="0"
max="1000"
step="0.1"
value={settings.min_safety_stock}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleNumberChange('min_safety_stock', e.target.value)}
disabled={disabled}
className="w-full"
/>
</div>
{/* Max Safety Stock */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Stock de Seguridad Máximo (0-1000)
</label>
<Input
type="number"
min="0"
max="1000"
step="0.1"
value={settings.max_safety_stock}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleNumberChange('max_safety_stock', e.target.value)}
disabled={disabled}
className="w-full"
/>
</div>
{/* Reorder Point Calculation */}
<div className="space-y-2 md:col-span-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Método de Punto de Reorden
</label>
<Select
value={settings.reorder_point_calculation}
onChange={(value) => handleStringChange('reorder_point_calculation', value)}
disabled={disabled}
options={[
{ value: 'safety_stock_plus_lead_time_demand', label: 'Stock de Seguridad + Demanda de Tiempo de Entrega' },
{ value: 'safety_stock_only', label: 'Solo Stock de Seguridad' },
{ value: 'fixed_quantity', label: 'Cantidad Fija' },
]}
/>
</div>
</div>
</div>
</Card>
);
};
export default SafetyStockSettingsCard;

View File

@@ -0,0 +1,152 @@
import React from 'react';
import { Card } from '@components/ui';
import { SupplierSelectionSettings } from '@services/types/settings';
import { Slider } from '@components/ui/Slider';
import { Input } from '@components/ui/Input';
interface SupplierSelectionSettingsCardProps {
settings: SupplierSelectionSettings;
onChange: (settings: SupplierSelectionSettings) => void;
disabled?: boolean;
}
const SupplierSelectionSettingsCard: React.FC<SupplierSelectionSettingsCardProps> = ({
settings,
onChange,
disabled = false,
}) => {
const handleNumberChange = (field: keyof SupplierSelectionSettings, value: string) => {
const numValue = value === '' ? 0 : Number(value);
onChange({
...settings,
[field]: numValue,
});
};
const handleToggleChange = (field: keyof SupplierSelectionSettings, value: boolean) => {
onChange({
...settings,
[field]: value,
});
};
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-6">
Configuración de Selección de Proveedores
</h3>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Price Weight */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Peso del Precio ({(settings.price_weight * 100).toFixed(0)}%)
</label>
<Slider
min={0}
max={1}
step={0.01}
value={[settings.price_weight]}
onValueChange={([value]: number[]) => handleNumberChange('price_weight', value.toString())}
disabled={disabled}
/>
</div>
{/* Lead Time Weight */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Peso del Tiempo de Entrega ({(settings.lead_time_weight * 100).toFixed(0)}%)
</label>
<Slider
min={0}
max={1}
step={0.01}
value={[settings.lead_time_weight]}
onValueChange={([value]: number[]) => handleNumberChange('lead_time_weight', value.toString())}
disabled={disabled}
/>
</div>
{/* Quality Weight */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Peso de la Calidad ({(settings.quality_weight * 100).toFixed(0)}%)
</label>
<Slider
min={0}
max={1}
step={0.01}
value={[settings.quality_weight]}
onValueChange={([value]: number[]) => handleNumberChange('quality_weight', value.toString())}
disabled={disabled}
/>
</div>
{/* Reliability Weight */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Peso de la Confiabilidad ({(settings.reliability_weight * 100).toFixed(0)}%)
</label>
<Slider
min={0}
max={1}
step={0.01}
value={[settings.reliability_weight]}
onValueChange={([value]: number[]) => handleNumberChange('reliability_weight', value.toString())}
disabled={disabled}
/>
</div>
{/* Diversification Threshold */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Umbral de Diversificación (0-1000)
</label>
<Input
type="number"
min="0"
max="1000"
value={settings.diversification_threshold}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleNumberChange('diversification_threshold', e.target.value)}
disabled={disabled}
className="w-full"
/>
</div>
{/* Max Single Percentage */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-secondary)]">
Máximo % para Proveedor Único ({(settings.max_single_percentage * 100).toFixed(0)}%)
</label>
<Slider
min={0}
max={1}
step={0.01}
value={[settings.max_single_percentage]}
onValueChange={([value]: number[]) => handleNumberChange('max_single_percentage', value.toString())}
disabled={disabled}
/>
</div>
</div>
{/* Enable Supplier Score Optimization Toggle */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enable_supplier_score_optimization"
checked={settings.enable_supplier_score_optimization}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleToggleChange('enable_supplier_score_optimization', e.target.checked)}
disabled={disabled}
className="rounded border-[var(--border-primary)]"
/>
<label htmlFor="enable_supplier_score_optimization" className="text-sm text-[var(--text-secondary)]">
Habilitar Optimización por Puntuación de Proveedores
</label>
</div>
</div>
</Card>
);
};
export default SupplierSelectionSettingsCard;

View File

@@ -2,7 +2,7 @@ import React, { useState, useMemo } from 'react';
import { Brain, TrendingUp, AlertCircle, Play, RotateCcw, Eye, Loader, CheckCircle } from 'lucide-react';
import { Button, Badge, Modal, Table, Select, StatsGrid, StatusCard, SearchAndFilter, type FilterConfig, Card, EmptyState } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { showToast } from '../../../../utils/toast';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useIngredients } from '../../../../api/hooks/inventory';
import {
@@ -40,7 +40,7 @@ interface ModelStatus {
}
const ModelsConfigPage: React.FC = () => {
const { addToast } = useToast();
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
@@ -160,10 +160,10 @@ const ModelsConfigPage: React.FC = () => {
request: trainingSettings
});
addToast(`Entrenamiento iniciado para ${selectedIngredient.name}`, { type: 'success' });
showToast.success(`Entrenamiento iniciado para ${selectedIngredient.name}`);
setShowTrainingModal(false);
} catch (error) {
addToast('Error al iniciar el entrenamiento', { type: 'error' });
showToast.error('Error al iniciar el entrenamiento');
}
};
@@ -206,12 +206,12 @@ const ModelsConfigPage: React.FC = () => {
request: settings
});
addToast(`Reentrenamiento iniciado para ${selectedIngredient.name}`, { type: 'success' });
showToast.success(`Reentrenamiento iniciado para ${selectedIngredient.name}`);
setShowRetrainModal(false);
setSelectedIngredient(null);
setSelectedModel(null);
} catch (error) {
addToast('Error al reentrenar el modelo', { type: 'error' });
showToast.error('Error al reentrenar el modelo');
}
};

View File

@@ -7,7 +7,7 @@ import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { useIngredients } from '../../../../api/hooks/inventory';
import { useTenantId } from '../../../../hooks/useTenantId';
import { ProductType, ProductCategory, IngredientResponse } from '../../../../api/types/inventory';
import { useToast } from '../../../../hooks/ui/useToast';
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';
@@ -546,7 +546,7 @@ const POSPage: React.FC = () => {
const [testingConnection, setTestingConnection] = useState<string | null>(null);
const tenantId = useTenantId();
const { addToast } = useToast();
// POS Configuration hooks
const posData = usePOSConfigurationData(tenantId);
@@ -674,12 +674,12 @@ const POSPage: React.FC = () => {
});
if (response.success) {
addToast('Conexión exitosa', { type: 'success' });
showToast.success('Conexión exitosa');
} else {
addToast(`Error en la conexión: ${response.message || 'Error desconocido'}`, { type: 'error' });
showToast.error(`Error en la conexión: ${response.message || 'Error desconocido'}`);
}
} catch (error) {
addToast('Error al probar la conexión', { type: 'error' });
showToast.error('Error al probar la conexión');
} finally {
setTestingConnection(null);
}
@@ -695,10 +695,10 @@ const POSPage: React.FC = () => {
tenant_id: tenantId,
config_id: configId,
});
addToast('Configuración eliminada correctamente', { type: 'success' });
showToast.success('Configuración eliminada correctamente');
loadPosConfigurations();
} catch (error) {
addToast('Error al eliminar la configuración', { type: 'error' });
showToast.error('Error al eliminar la configuración');
}
};
@@ -762,7 +762,7 @@ const POSPage: React.FC = () => {
});
setCart([]);
addToast('Venta procesada exitosamente', { type: 'success' });
showToast.success('Venta procesada exitosamente');
};
// Loading and error states

View File

@@ -15,7 +15,7 @@ import { useTriggerDailyScheduler } from '../../../../api';
import type { PurchaseOrderStatus, PurchaseOrderPriority, PurchaseOrderDetail } from '../../../../api/services/purchase_orders';
import { useTenantStore } from '../../../../stores/tenant.store';
import { useUserById } from '../../../../api/hooks/user';
import toast from 'react-hot-toast';
import { showToast } from '../../../../utils/toast';
const ProcurementPage: React.FC = () => {
// State
@@ -59,7 +59,6 @@ const ProcurementPage: React.FC = () => {
const approvePOMutation = useApprovePurchaseOrder();
const rejectPOMutation = useRejectPurchaseOrder();
const updatePOMutation = useUpdatePurchaseOrder();
const triggerSchedulerMutation = useTriggerDailyScheduler();
// Filter POs
const filteredPOs = useMemo(() => {
@@ -129,11 +128,11 @@ const ProcurementPage: React.FC = () => {
poId: po.id,
data: { status: 'SENT_TO_SUPPLIER' }
});
toast.success('Orden enviada al proveedor');
showToast.success('Orden enviada al proveedor');
refetchPOs();
} catch (error) {
console.error('Error sending PO to supplier:', error);
toast.error('Error al enviar orden al proveedor');
showToast.error('Error al enviar orden al proveedor');
}
};
@@ -144,11 +143,11 @@ const ProcurementPage: React.FC = () => {
poId: po.id,
data: { status: 'CONFIRMED' }
});
toast.success('Orden confirmada');
showToast.success('Orden confirmada');
refetchPOs();
} catch (error) {
console.error('Error confirming PO:', error);
toast.error('Error al confirmar orden');
showToast.error('Error al confirmar orden');
}
};
@@ -162,10 +161,10 @@ const ProcurementPage: React.FC = () => {
poId: selectedPOId,
notes: approvalNotes || undefined
});
toast.success('Orden aprobada exitosamente');
showToast.success('Orden aprobada exitosamente');
} else {
if (!approvalNotes.trim()) {
toast.error('Debes proporcionar una razón para rechazar');
showToast.error('Debes proporcionar una razón para rechazar');
return;
}
await rejectPOMutation.mutateAsync({
@@ -173,7 +172,7 @@ const ProcurementPage: React.FC = () => {
poId: selectedPOId,
reason: approvalNotes
});
toast.success('Orden rechazada');
showToast.success('Orden rechazada');
}
setShowApprovalModal(false);
setShowDetailsModal(false);
@@ -181,18 +180,18 @@ const ProcurementPage: React.FC = () => {
refetchPOs();
} catch (error) {
console.error('Error in approval action:', error);
toast.error('Error al procesar aprobación');
showToast.error('Error al procesar aprobación');
}
};
const handleTriggerScheduler = async () => {
try {
await triggerSchedulerMutation.mutateAsync(tenantId);
toast.success('Scheduler ejecutado exitosamente');
showToast.success('Scheduler ejecutado exitosamente');
refetchPOs();
} catch (error) {
console.error('Error triggering scheduler:', error);
toast.error('Error al ejecutar scheduler');
showToast.error('Error al ejecutar scheduler');
}
};
@@ -715,16 +714,6 @@ const ProcurementPage: React.FC = () => {
title="Órdenes de Compra"
description="Gestiona órdenes de compra y aprovisionamiento"
actions={[
{
id: 'trigger-scheduler',
label: triggerSchedulerMutation.isPending ? 'Ejecutando...' : 'Ejecutar Scheduler',
icon: Play,
onClick: handleTriggerScheduler,
variant: 'outline',
size: 'sm',
disabled: triggerSchedulerMutation.isPending,
loading: triggerSchedulerMutation.isPending
},
{
id: 'create-po',
label: 'Nueva Orden',
@@ -857,7 +846,7 @@ const ProcurementPage: React.FC = () => {
onSuccess={() => {
setShowCreatePOModal(false);
refetchPOs();
toast.success('Orden de compra creada exitosamente');
showToast.success('Orden de compra creada exitosamente');
}}
/>
)}

View File

@@ -26,7 +26,7 @@ import {
} from '../../../../api';
import { useTranslation } from 'react-i18next';
import { ProcessStage } from '../../../../api/types/qualityTemplates';
import toast from 'react-hot-toast';
import { showToast } from '../../../../utils/toast';
const ProductionPage: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
@@ -58,7 +58,6 @@ const ProductionPage: React.FC = () => {
// Mutations
const createBatchMutation = useCreateProductionBatch();
const updateBatchStatusMutation = useUpdateBatchStatus();
const triggerSchedulerMutation = useTriggerProductionScheduler();
// Handlers
const handleCreateBatch = async (batchData: ProductionBatchCreate) => {
@@ -76,10 +75,10 @@ const ProductionPage: React.FC = () => {
const handleTriggerScheduler = async () => {
try {
await triggerSchedulerMutation.mutateAsync(tenantId);
toast.success('Scheduler ejecutado exitosamente');
showToast.success('Scheduler ejecutado exitosamente');
} catch (error) {
console.error('Error triggering scheduler:', error);
toast.error('Error al ejecutar scheduler');
showToast.error('Error al ejecutar scheduler');
}
};
@@ -300,16 +299,6 @@ const ProductionPage: React.FC = () => {
title="Gestión de Producción"
description="Planifica y controla la producción diaria de tu panadería"
actions={[
{
id: 'trigger-scheduler',
label: triggerSchedulerMutation.isPending ? 'Ejecutando...' : 'Ejecutar Scheduler',
icon: Play,
onClick: handleTriggerScheduler,
variant: 'outline',
size: 'sm',
disabled: triggerSchedulerMutation.isPending,
loading: triggerSchedulerMutation.isPending
},
{
id: 'create-batch',
label: 'Nueva Orden de Producción',
@@ -731,4 +720,4 @@ const ProductionPage: React.FC = () => {
);
};
export default ProductionPage;
export default ProductionPage;

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { Store, MapPin, Clock, Phone, Mail, Globe, Save, X, Edit3, Zap, Plus, Settings, Trash2, Wifi, WifiOff, AlertCircle, CheckCircle, Loader, Eye, EyeOff, Info } from 'lucide-react';
import { Button, Card, Input, Select, Modal, Badge, Tabs } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { showToast } from '../../../../utils/toast';
import { usePOSConfigurationData, usePOSConfigurationManager } from '../../../../api/hooks/pos';
import { POSConfiguration, POSProviderConfig } from '../../../../api/types/pos';
import { posService } from '../../../../api/services/pos';
@@ -38,7 +38,7 @@ interface BusinessHours {
const BakeryConfigPage: React.FC = () => {
const { addToast } = useToast();
const currentTenant = useCurrentTenant();
const { loadUserTenants, setCurrentTenant } = useTenantActions();
const tenantId = currentTenant?.id || '';
@@ -287,9 +287,9 @@ const BakeryConfigPage: React.FC = () => {
}
setHasUnsavedChanges(false);
addToast('Configuración actualizada correctamente', { type: 'success' });
showToast.success('Configuración actualizada correctamente');
} catch (error) {
addToast(`Error al actualizar: ${error instanceof Error ? error.message : 'Error desconocido'}`, { type: 'error' });
showToast.error(`Error al actualizar: ${error instanceof Error ? error.message : 'Error desconocido'}`);
} finally {
setIsLoading(false);
}
@@ -364,7 +364,7 @@ const BakeryConfigPage: React.FC = () => {
.map(field => field.label);
if (missingFields.length > 0) {
addToast(`Campos requeridos: ${missingFields.join(', ')}`, 'error');
showToast.error(`Campos requeridos: ${missingFields.join(', ')}`);
return;
}
@@ -375,7 +375,7 @@ const BakeryConfigPage: React.FC = () => {
config_id: selectedPosConfig.id,
...posFormData,
});
addToast('Configuración actualizada correctamente', 'success');
showToast.success('Configuración actualizada correctamente');
setShowEditPosModal(false);
loadPosConfigurations();
} else {
@@ -384,12 +384,12 @@ const BakeryConfigPage: React.FC = () => {
tenant_id: tenantId,
...posFormData,
});
addToast('Configuración creada correctamente', 'success');
showToast.success('Configuración creada correctamente');
setShowAddPosModal(false);
loadPosConfigurations();
}
} catch (error) {
addToast('Error al guardar la configuración', 'error');
showToast.error('Error al guardar la configuración');
}
};
@@ -402,12 +402,12 @@ const BakeryConfigPage: React.FC = () => {
});
if (response.success) {
addToast('Conexión exitosa', 'success');
showToast.success('Conexión exitosa');
} else {
addToast(`Error en la conexión: ${response.message || 'Error desconocido'}`, 'error');
showToast.error(`Error en la conexión: ${response.message || 'Error desconocido'}`);
}
} catch (error) {
addToast('Error al probar la conexión', 'error');
showToast.error('Error al probar la conexión');
} finally {
setTestingConnection(null);
}
@@ -423,10 +423,10 @@ const BakeryConfigPage: React.FC = () => {
tenant_id: tenantId,
config_id: configId,
});
addToast('Configuración eliminada correctamente', 'success');
showToast.success('Configuración eliminada correctamente');
loadPosConfigurations();
} catch (error) {
addToast('Error al eliminar la configuración', 'error');
showToast.error('Error al eliminar la configuración');
}
};
@@ -1116,4 +1116,4 @@ const BakeryConfigPage: React.FC = () => {
);
};
export default BakeryConfigPage;
export default BakeryConfigPage;

View File

@@ -4,7 +4,7 @@ import { Store, MapPin, Clock, Settings as SettingsIcon, Save, X, AlertCircle, L
import { Button, Card, Input, Select } from '../../../../components/ui';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { showToast } from '../../../../utils/toast';
import { useUpdateTenant } from '../../../../api/hooks/tenant';
import { useCurrentTenant, useTenantActions } from '../../../../stores/tenant.store';
import { useSettings, useUpdateSettings } from '../../../../api/hooks/settings';
@@ -49,7 +49,7 @@ interface BusinessHours {
const BakerySettingsPage: React.FC = () => {
const { t } = useTranslation('settings');
const { addToast } = useToast();
const currentTenant = useCurrentTenant();
const { loadUserTenants, setCurrentTenant } = useTenantActions();
const tenantId = currentTenant?.id || '';
@@ -221,10 +221,10 @@ const BakerySettingsPage: React.FC = () => {
}
setHasUnsavedChanges(false);
addToast(t('bakery.save_success'), { type: 'success' });
showToast.success(t('bakery.save_success'));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t('common.error');
addToast(`${t('bakery.save_error')}: ${errorMessage}`, { type: 'error' });
showToast.error(`${t('bakery.save_error')}: ${errorMessage}`);
} finally {
setIsLoading(false);
}
@@ -252,10 +252,10 @@ const BakerySettingsPage: React.FC = () => {
});
setHasUnsavedChanges(false);
addToast(t('bakery.save_success'), { type: 'success' });
showToast.success(t('bakery.save_success'));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t('common.error');
addToast(`${t('bakery.save_error')}: ${errorMessage}`, { type: 'error' });
showToast.error(`${t('bakery.save_error')}: ${errorMessage}`);
} finally {
setIsLoading(false);
}

View File

@@ -23,7 +23,7 @@ import {
Sun,
Settings
} from 'lucide-react';
import { useToast } from '../../../../hooks/ui/useToast';
import { showToast } from '../../../../utils/toast';
// Backend-aligned preference types
export interface NotificationPreferences {
@@ -75,7 +75,7 @@ const CommunicationPreferences: React.FC<CommunicationPreferencesProps> = ({
onReset,
hasChanges
}) => {
const { addToast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [preferences, setPreferences] = useState<NotificationPreferences>({
@@ -161,9 +161,9 @@ const CommunicationPreferences: React.FC<CommunicationPreferencesProps> = ({
try {
setIsLoading(true);
await onSave(preferences);
addToast('Preferencias guardadas correctamente', 'success');
showToast.success('Preferencias guardadas correctamente');
} catch (error) {
addToast('Error al guardar las preferencias', 'error');
showToast.error('Error al guardar las preferencias');
} finally {
setIsLoading(false);
}
@@ -700,4 +700,4 @@ const CommunicationPreferences: React.FC<CommunicationPreferencesProps> = ({
);
};
export default CommunicationPreferences;
export default CommunicationPreferences;

View File

@@ -22,7 +22,7 @@ import {
import { Button, Card, Avatar, Input, Select } from '../../../../components/ui';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { showToast } from '../../../../utils/toast';
import { useAuthUser, useAuthActions } from '../../../../stores/auth.store';
import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth';
import { useCurrentTenant } from '../../../../stores';
@@ -49,7 +49,7 @@ interface PasswordData {
const NewProfileSettingsPage: React.FC = () => {
const { t } = useTranslation('settings');
const navigate = useNavigate();
const { addToast } = useToast();
const user = useAuthUser();
const { logout } = useAuthActions();
const currentTenant = useCurrentTenant();
@@ -169,9 +169,9 @@ const NewProfileSettingsPage: React.FC = () => {
await updateProfileMutation.mutateAsync(profileData);
setIsEditing(false);
addToast(t('profile.save_changes'), { type: 'success' });
showToast.success(t('profile.save_changes'));
} catch (error) {
addToast(t('common.error'), { type: 'error' });
showToast.error(t('common.error'));
} finally {
setIsLoading(false);
}
@@ -191,9 +191,9 @@ const NewProfileSettingsPage: React.FC = () => {
setShowPasswordForm(false);
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
addToast(t('profile.password.change_success'), { type: 'success' });
showToast.success(t('profile.password.change_success'));
} catch (error) {
addToast(t('profile.password.change_error'), { type: 'error' });
showToast.error(t('profile.password.change_error'));
} finally {
setIsLoading(false);
}
@@ -246,9 +246,9 @@ const NewProfileSettingsPage: React.FC = () => {
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
addToast(t('profile.privacy.export_success'), { type: 'success' });
showToast.success(t('profile.privacy.export_success'));
} catch (err) {
addToast(t('profile.privacy.export_error'), { type: 'error' });
showToast.error(t('profile.privacy.export_error'));
} finally {
setIsExporting(false);
}
@@ -256,12 +256,12 @@ const NewProfileSettingsPage: React.FC = () => {
const handleAccountDeletion = async () => {
if (deleteConfirmEmail.toLowerCase() !== user?.email?.toLowerCase()) {
addToast(t('common.error'), { type: 'error' });
showToast.error(t('common.error'));
return;
}
if (!deletePassword) {
addToast(t('common.error'), { type: 'error' });
showToast.error(t('common.error'));
return;
}
@@ -270,14 +270,14 @@ const NewProfileSettingsPage: React.FC = () => {
const { authService } = await import('../../../../api');
await authService.deleteAccount(deleteConfirmEmail, deletePassword, deleteReason);
addToast(t('common.success'), { type: 'success' });
showToast.success(t('common.success'));
setTimeout(() => {
logout();
navigate('/');
}, 2000);
} catch (err: any) {
addToast(err.message || t('common.error'), { type: 'error' });
showToast.error(err.message || t('common.error'));
} finally {
setIsDeleting(false);
}

View File

@@ -4,7 +4,7 @@ import { Button, Card, Avatar, Input, Select, Tabs, Badge, Modal } from '../../.
import { PageHeader } from '../../../../components/layout';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant } from '../../../../stores';
import { useToast } from '../../../../hooks/ui/useToast';
import { showToast } from '../../../../utils/toast';
import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth';
import { subscriptionService, type UsageSummary, type AvailablePlans } from '../../../../api';
import { useTranslation } from 'react-i18next';
@@ -30,7 +30,7 @@ interface PasswordData {
const ProfilePage: React.FC = () => {
const user = useAuthUser();
const { t } = useTranslation(['settings', 'auth']);
const { addToast } = useToast();
const { data: profile, isLoading: profileLoading, error: profileError } = useAuthProfile();
const updateProfileMutation = useUpdateProfile();
@@ -176,9 +176,9 @@ const ProfilePage: React.FC = () => {
await updateProfileMutation.mutateAsync(profileData);
setIsEditing(false);
addToast('Perfil actualizado correctamente', 'success');
showToast.success('Perfil actualizado correctamente');
} catch (error) {
addToast('No se pudo actualizar tu perfil', 'error');
showToast.error('No se pudo actualizar tu perfil');
} finally {
setIsLoading(false);
}
@@ -198,9 +198,9 @@ const ProfilePage: React.FC = () => {
setShowPasswordForm(false);
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
addToast('Contraseña actualizada correctamente', 'success');
showToast.success('Contraseña actualizada correctamente');
} catch (error) {
addToast('No se pudo cambiar tu contraseña', 'error');
showToast.error('No se pudo cambiar tu contraseña');
} finally {
setIsLoading(false);
}
@@ -269,7 +269,7 @@ const ProfilePage: React.FC = () => {
const tenantId = currentTenant?.id || user?.tenant_id;
if (!tenantId) {
addToast('No se encontró información del tenant', 'error');
showToast.error('No se encontró información del tenant');
return;
}
@@ -284,7 +284,7 @@ const ProfilePage: React.FC = () => {
setAvailablePlans(plans);
} catch (error) {
console.error('Error loading subscription data:', error);
addToast("No se pudo cargar la información de suscripción", 'error');
showToast.error("No se pudo cargar la información de suscripción");
} finally {
setSubscriptionLoading(false);
}
@@ -299,7 +299,7 @@ const ProfilePage: React.FC = () => {
const tenantId = currentTenant?.id || user?.tenant_id;
if (!tenantId || !selectedPlan) {
addToast('Información de tenant no disponible', 'error');
showToast.error('Información de tenant no disponible');
return;
}
@@ -312,24 +312,24 @@ const ProfilePage: React.FC = () => {
);
if (!validation.can_upgrade) {
addToast(validation.reason || 'No se puede actualizar el plan', 'error');
return;
showToast.error(validation.reason || 'No se puede actualizar el plan');
return;
}
const result = await subscriptionService.upgradePlan(tenantId, selectedPlan);
if (result.success) {
addToast(result.message, 'success');
showToast.success(result.message);
await loadSubscriptionData();
setUpgradeDialogOpen(false);
setSelectedPlan('');
} else {
addToast('Error al cambiar el plan', 'error');
showToast.error('Error al cambiar el plan');
}
} catch (error) {
console.error('Error upgrading plan:', error);
addToast('Error al procesar el cambio de plan', 'error');
showToast.error('Error al procesar el cambio de plan');
} finally {
setUpgrading(false);
}
@@ -953,4 +953,4 @@ const ProfilePage: React.FC = () => {
);
};
export default ProfilePage;
export default ProfilePage;

View File

@@ -5,7 +5,7 @@ import { DialogModal } from '../../../../components/ui/DialogModal/DialogModal';
import { PageHeader } from '../../../../components/layout';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant } from '../../../../stores';
import { useToast } from '../../../../hooks/ui/useToast';
import { showToast } from '../../../../utils/toast';
import { subscriptionService, type UsageSummary, type AvailablePlans } from '../../../../api';
import { useSubscriptionEvents } from '../../../../contexts/SubscriptionEventsContext';
import { SubscriptionPricingCards } from '../../../../components/subscription/SubscriptionPricingCards';
@@ -13,7 +13,6 @@ import { SubscriptionPricingCards } from '../../../../components/subscription/Su
const SubscriptionPage: React.FC = () => {
const user = useAuthUser();
const currentTenant = useCurrentTenant();
const { addToast } = useToast();
const { notifySubscriptionChanged } = useSubscriptionEvents();
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
@@ -36,7 +35,7 @@ const SubscriptionPage: React.FC = () => {
const tenantId = currentTenant?.id || user?.tenant_id;
if (!tenantId) {
addToast('No se encontró información del tenant', { type: 'error' });
showToast.error('No se encontró información del tenant');
return;
}
@@ -120,7 +119,7 @@ const SubscriptionPage: React.FC = () => {
setAvailablePlans(plans);
} catch (error) {
console.error('Error loading subscription data:', error);
addToast("No se pudo cargar la información de suscripción", { type: 'error' });
showToast.error("No se pudo cargar la información de suscripción");
} finally {
setSubscriptionLoading(false);
}
@@ -135,7 +134,7 @@ const SubscriptionPage: React.FC = () => {
const tenantId = currentTenant?.id || user?.tenant_id;
if (!tenantId || !selectedPlan) {
addToast('Información de tenant no disponible', { type: 'error' });
showToast.error('Información de tenant no disponible');
return;
}
@@ -148,14 +147,17 @@ const SubscriptionPage: React.FC = () => {
);
if (!validation.can_upgrade) {
addToast(validation.reason || 'No se puede actualizar el plan', { type: 'error' });
showToast.error(validation.reason || 'No se puede actualizar el plan');
return;
}
const result = await subscriptionService.upgradePlan(tenantId, selectedPlan);
if (result.success) {
addToast(result.message, { type: 'success' });
showToast.success(result.message);
// Invalidate cache to ensure fresh data on next fetch
subscriptionService.invalidateCache();
// Broadcast subscription change event to refresh sidebar and other components
notifySubscriptionChanged();
@@ -164,11 +166,11 @@ const SubscriptionPage: React.FC = () => {
setUpgradeDialogOpen(false);
setSelectedPlan('');
} else {
addToast('Error al cambiar el plan', { type: 'error' });
showToast.error('Error al cambiar el plan');
}
} catch (error) {
console.error('Error upgrading plan:', error);
addToast('Error al procesar el cambio de plan', { type: 'error' });
showToast.error('Error al procesar el cambio de plan');
} finally {
setUpgrading(false);
}
@@ -182,7 +184,7 @@ const SubscriptionPage: React.FC = () => {
const tenantId = currentTenant?.id || user?.tenant_id;
if (!tenantId) {
addToast('Información de tenant no disponible', { type: 'error' });
showToast.error('Información de tenant no disponible');
return;
}
@@ -199,9 +201,8 @@ const SubscriptionPage: React.FC = () => {
day: 'numeric'
});
addToast(
`Suscripción cancelada. Acceso de solo lectura a partir del ${effectiveDate} (${daysRemaining} días restantes)`,
{ type: 'success' }
showToast.success(
`Suscripción cancelada. Acceso de solo lectura a partir del ${effectiveDate} (${daysRemaining} días restantes)`
);
}
@@ -209,7 +210,7 @@ const SubscriptionPage: React.FC = () => {
setCancellationDialogOpen(false);
} catch (error) {
console.error('Error cancelling subscription:', error);
addToast('Error al cancelar la suscripción', { type: 'error' });
showToast.error('Error al cancelar la suscripción');
} finally {
setCancelling(false);
}
@@ -219,7 +220,7 @@ const SubscriptionPage: React.FC = () => {
const tenantId = currentTenant?.id || user?.tenant_id;
if (!tenantId) {
addToast('No se encontró información del tenant', { type: 'error' });
showToast.error('No se encontró información del tenant');
return;
}
@@ -236,7 +237,7 @@ const SubscriptionPage: React.FC = () => {
]);
} catch (error) {
console.error('Error loading invoices:', error);
addToast('Error al cargar las facturas', { type: 'error' });
showToast.error('Error al cargar las facturas');
} finally {
setInvoicesLoading(false);
}
@@ -245,7 +246,7 @@ const SubscriptionPage: React.FC = () => {
const handleDownloadInvoice = (invoiceId: string) => {
// In a real implementation, this would download the actual invoice
console.log(`Downloading invoice: ${invoiceId}`);
addToast(`Descargando factura ${invoiceId}`, { type: 'info' });
showToast.info(`Descargando factura ${invoiceId}`);
};
const ProgressBar: React.FC<{ value: number; className?: string }> = ({ value, className = '' }) => {
@@ -389,7 +390,7 @@ const SubscriptionPage: React.FC = () => {
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.users.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.users.unlimited ? 'Ilimitado' : `${usageSummary.usage.users.limit - usageSummary.usage.users.current} restantes`}</span>
<span className="font-medium">{usageSummary.usage.users.unlimited ? 'Ilimitado' : `${(usageSummary.usage.users.limit ?? 0) - usageSummary.usage.users.current} restantes`}</span>
</p>
</div>
@@ -410,7 +411,7 @@ const SubscriptionPage: React.FC = () => {
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.locations.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.locations.unlimited ? 'Ilimitado' : `${usageSummary.usage.locations.limit - usageSummary.usage.locations.current} restantes`}</span>
<span className="font-medium">{usageSummary.usage.locations.unlimited ? 'Ilimitado' : `${(usageSummary.usage.locations.limit ?? 0) - usageSummary.usage.locations.current} restantes`}</span>
</p>
</div>
</div>
@@ -437,7 +438,7 @@ const SubscriptionPage: React.FC = () => {
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.products.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.products.unlimited ? 'Ilimitado' : `${usageSummary.usage.products.limit - usageSummary.usage.products.current} restantes`}</span>
<span className="font-medium">{usageSummary.usage.products.unlimited ? 'Ilimitado' : `${(usageSummary.usage.products.limit ?? 0) - usageSummary.usage.products.current} restantes`}</span>
</p>
</div>
@@ -458,7 +459,7 @@ const SubscriptionPage: React.FC = () => {
<ProgressBar value={usageSummary.usage.recipes.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.recipes.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.recipes.unlimited ? 'Ilimitado' : `${usageSummary.usage.recipes.limit - usageSummary.usage.recipes.current} restantes`}</span>
<span className="font-medium">{usageSummary.usage.recipes.unlimited ? 'Ilimitado' : `${(usageSummary.usage.recipes.limit ?? 0) - usageSummary.usage.recipes.current} restantes`}</span>
</p>
</div>
@@ -479,7 +480,7 @@ const SubscriptionPage: React.FC = () => {
<ProgressBar value={usageSummary.usage.suppliers.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.suppliers.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.suppliers.unlimited ? 'Ilimitado' : `${usageSummary.usage.suppliers.limit - usageSummary.usage.suppliers.current} restantes`}</span>
<span className="font-medium">{usageSummary.usage.suppliers.unlimited ? 'Ilimitado' : `${(usageSummary.usage.suppliers.limit ?? 0) - usageSummary.usage.suppliers.current} restantes`}</span>
</p>
</div>
</div>
@@ -506,7 +507,7 @@ const SubscriptionPage: React.FC = () => {
<ProgressBar value={usageSummary.usage.training_jobs_today.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.training_jobs_today.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.training_jobs_today.unlimited ? 'Ilimitado' : `${usageSummary.usage.training_jobs_today.limit - usageSummary.usage.training_jobs_today.current} restantes`}</span>
<span className="font-medium">{usageSummary.usage.training_jobs_today.unlimited ? 'Ilimitado' : `${(usageSummary.usage.training_jobs_today.limit ?? 0) - usageSummary.usage.training_jobs_today.current} restantes`}</span>
</p>
</div>
@@ -527,7 +528,7 @@ const SubscriptionPage: React.FC = () => {
<ProgressBar value={usageSummary.usage.forecasts_today.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.forecasts_today.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.forecasts_today.unlimited ? 'Ilimitado' : `${usageSummary.usage.forecasts_today.limit - usageSummary.usage.forecasts_today.current} restantes`}</span>
<span className="font-medium">{usageSummary.usage.forecasts_today.unlimited ? 'Ilimitado' : `${(usageSummary.usage.forecasts_today.limit ?? 0) - usageSummary.usage.forecasts_today.current} restantes`}</span>
</p>
</div>
</div>
@@ -554,7 +555,7 @@ const SubscriptionPage: React.FC = () => {
<ProgressBar value={usageSummary.usage.api_calls_this_hour.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.api_calls_this_hour.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.api_calls_this_hour.unlimited ? 'Ilimitado' : `${usageSummary.usage.api_calls_this_hour.limit - usageSummary.usage.api_calls_this_hour.current} restantes`}</span>
<span className="font-medium">{usageSummary.usage.api_calls_this_hour.unlimited ? 'Ilimitado' : `${(usageSummary.usage.api_calls_this_hour.limit ?? 0) - usageSummary.usage.api_calls_this_hour.current} restantes`}</span>
</p>
</div>
@@ -575,7 +576,7 @@ const SubscriptionPage: React.FC = () => {
<ProgressBar value={usageSummary.usage.file_storage_used_gb.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.file_storage_used_gb.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.file_storage_used_gb.unlimited ? 'Ilimitado' : `${(usageSummary.usage.file_storage_used_gb.limit - usageSummary.usage.file_storage_used_gb.current).toFixed(2)} GB restantes`}</span>
<span className="font-medium">{usageSummary.usage.file_storage_used_gb.unlimited ? 'Ilimitado' : `${((usageSummary.usage.file_storage_used_gb.limit ?? 0) - usageSummary.usage.file_storage_used_gb.current).toFixed(2)} GB restantes`}</span>
</p>
</div>
</div>

View File

@@ -9,13 +9,13 @@ import { useUserActivity } from '../../../../api/hooks/user';
import { userService } from '../../../../api/services/user';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
import { useToast } from '../../../../hooks/ui/useToast';
import { showToast } from '../../../../utils/toast';
import { TENANT_ROLES, type TenantRole } from '../../../../types/roles';
import { subscriptionService } from '../../../../api/services/subscription';
const TeamPage: React.FC = () => {
const { t } = useTranslation(['settings']);
const { addToast } = useToast();
const currentUser = useAuthUser();
const currentTenant = useCurrentTenant();
const currentTenantAccess = useCurrentTenantAccess();
@@ -310,7 +310,7 @@ const TeamPage: React.FC = () => {
setShowActivityModal(true);
} catch (error) {
console.error('Error fetching user activity:', error);
addToast('Error al cargar la actividad del usuario', { type: 'error' });
showToast.error('Error al cargar la actividad del usuario');
} finally {
setActivityLoading(false);
}
@@ -359,9 +359,9 @@ const TeamPage: React.FC = () => {
memberUserId,
});
addToast('Miembro removido exitosamente', { type: 'success' });
showToast.success('Miembro removido exitosamente');
} catch (error) {
addToast('Error al remover miembro', { type: 'error' });
showToast.error('Error al remover miembro');
}
};
@@ -375,9 +375,9 @@ const TeamPage: React.FC = () => {
newRole,
});
addToast('Rol actualizado exitosamente', { type: 'success' });
showToast.success('Rol actualizado exitosamente');
} catch (error) {
addToast('Error al actualizar rol', { type: 'error' });
showToast.error('Error al actualizar rol');
}
};
@@ -556,7 +556,7 @@ const TeamPage: React.FC = () => {
if (!usageCheck.allowed) {
const errorMessage = usageCheck.message ||
`Has alcanzado el límite de ${usageCheck.limit} usuarios para tu plan. Actualiza tu suscripción para agregar más miembros.`;
addToast(errorMessage, { type: 'error' });
showToast.error(errorMessage);
throw new Error(errorMessage);
}
@@ -579,14 +579,14 @@ const TeamPage: React.FC = () => {
timezone: 'Europe/Madrid'
}
});
addToast('Usuario creado y agregado exitosamente', { type: 'success' });
showToast.success('Usuario creado y agregado exitosamente');
} else {
await addMemberMutation.mutateAsync({
tenantId,
userId: userData.userId!,
role,
});
addToast('Miembro agregado exitosamente', { type: 'success' });
showToast.success('Miembro agregado exitosamente');
}
setShowAddForm(false);
@@ -597,9 +597,8 @@ const TeamPage: React.FC = () => {
// Limit error already toasted above
throw error;
}
addToast(
userData.createUser ? 'Error al crear usuario' : 'Error al agregar miembro',
{ type: 'error' }
showToast.error(
userData.createUser ? 'Error al crear usuario' : 'Error al agregar miembro'
);
throw error;
}

View File

@@ -9,12 +9,12 @@ import {
getCookieCategories,
CookiePreferences
} from '../../components/ui/CookieConsent';
import { useToast } from '../../hooks/ui/useToast';
import { showToast } from '../../utils/toast';
export const CookiePreferencesPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { success } = useToast();
const [preferences, setPreferences] = useState<CookiePreferences>({
essential: true,
@@ -48,7 +48,7 @@ export const CookiePreferencesPage: React.FC = () => {
};
saveCookieConsent(updatedPreferences);
success(
showToast.success(
t('common:cookie.preferences_saved', 'Your cookie preferences have been saved successfully.'),
{ title: t('common:cookie.success', 'Preferences Saved') }
);
@@ -66,7 +66,7 @@ export const CookiePreferencesPage: React.FC = () => {
saveCookieConsent(allEnabled);
setPreferences(allEnabled);
success(
showToast.success(
t('common:cookie.all_accepted', 'All cookies have been accepted.'),
{ title: t('common:cookie.success', 'Preferences Saved') }
);
@@ -84,7 +84,7 @@ export const CookiePreferencesPage: React.FC = () => {
saveCookieConsent(essentialOnly);
setPreferences(essentialOnly);
success(
showToast.success(
t('common:cookie.only_essential', 'Only essential cookies are enabled.'),
{ title: t('common:cookie.success', 'Preferences Saved') }
);

View File

@@ -32,7 +32,9 @@ import {
Target,
CheckCircle2,
Sparkles,
Recycle
Recycle,
MapPin,
Globe
} from 'lucide-react';
const LandingPage: React.FC = () => {
@@ -56,6 +58,7 @@ const LandingPage: React.FC = () => {
variant: "default",
navigationItems: [
{ id: 'features', label: t('landing:navigation.features', 'Características'), href: '#features' },
{ id: 'local', label: t('landing:navigation.local', 'Datos Locales'), href: '#local' },
{ id: 'benefits', label: t('landing:navigation.benefits', 'Beneficios'), href: '#benefits' },
{ id: 'pricing', label: t('landing:navigation.pricing', 'Precios'), href: '#pricing' },
{ id: 'faq', label: t('landing:navigation.faq', 'Preguntas Frecuentes'), href: '#faq' }
@@ -76,6 +79,10 @@ const LandingPage: React.FC = () => {
<Shield className="w-4 h-4 mr-2" />
{t('landing:hero.badge_sustainability', 'Reducción de Desperdicio Alimentario')}
</span>
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400">
<MapPin className="w-4 h-4 mr-2" />
{t('landing:hero.badge_local', 'Datos Hiperlocales Españoles')}
</span>
</div>
<h1 className="text-4xl tracking-tight font-extrabold text-[var(--text-primary)] sm:text-5xl lg:text-7xl">
@@ -178,7 +185,7 @@ const LandingPage: React.FC = () => {
</div>
</div>
</div>
{/* Background decoration */}
<div className="absolute top-0 left-0 right-0 h-full overflow-hidden -z-10">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-[var(--color-primary)]/5 rounded-full blur-3xl"></div>
@@ -581,6 +588,128 @@ const LandingPage: React.FC = () => {
</div>
</section>
{/* Hyper-Local Spanish Intelligence Section */}
<section id="local" className="py-24 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20">
<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 px-4 py-2 bg-blue-600 text-white rounded-full text-sm font-semibold mb-6">
<MapPin className="w-4 h-4" />
{t('landing:local.badge', 'Datos Hiperlocales Españoles')}
</div>
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6">
{t('landing:local.title_main', 'Inteligencia Hiperlocal')}
<span className="block text-[var(--color-primary)]">{t('landing:local.title_accent', 'para España')}</span>
</h2>
<p className="text-lg text-[var(--text-secondary)] max-w-3xl mx-auto">
{t('landing:local.subtitle', 'Nuestra IA está entrenada con datos hiperlocales españoles: información meteorológica AEMET, datos históricos de tráfico congestionado, y eventos culturales específicos de cada región. Comenzamos en Madrid, pero estamos preparados para tu ciudad con la misma precisión local.')}
</p>
</div>
<div className="grid md:grid-cols-3 gap-8 mb-16">
{/* Weather Data */}
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg border-2 border-blue-200 dark:border-blue-800 hover:border-blue-400 dark:hover:border-blue-600 transition-all duration-300">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl flex items-center justify-center mb-6 mx-auto">
<Droplets className="w-8 h-8 text-white" />
</div>
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-4 text-center">{t('landing:local.weather.title', 'Datos Meteorológicos AEMET')}</h3>
<p className="text-[var(--text-secondary)] text-center mb-6">
{t('landing:local.weather.description', 'Precisión meteorológica local con datos AEMET para predicciones hiperlocales que entienden las microclimas de tu ciudad.')}
</p>
<div className="space-y-3">
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
<span className="text-sm text-[var(--text-secondary)]">{t('landing:local.weather.features.aemet', 'Integración directa con AEMET')}</span>
</div>
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
<span className="text-sm text-[var(--text-secondary)]">{t('landing:local.weather.features.microclimate', 'Datos de microclima por ciudad')}</span>
</div>
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
<span className="text-sm text-[var(--text-secondary)]">{t('landing:local.weather.features.local', 'Adaptado a cada región española')}</span>
</div>
</div>
</div>
{/* Traffic Data */}
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg border-2 border-purple-200 dark:border-purple-800 hover:border-purple-40 dark:hover:border-purple-600 transition-all duration-300">
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-pink-600 rounded-2xl flex items-center justify-center mb-6 mx-auto">
<Globe className="w-8 h-8 text-white" />
</div>
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-4 text-center">{t('landing:local.traffic.title', 'Datos de Tráfico Históricos')}</h3>
<p className="text-[var(--text-secondary)] text-center mb-6">
{t('landing:local.traffic.description', 'Análisis de patrones de tráfico congestionado en ciudades españolas para entender mejor los flujos de clientes y demanda.')}
</p>
<div className="space-y-3">
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
<span className="text-sm text-[var(--text-secondary)]">{t('landing:local.traffic.features.historical', 'Datos históricos de tráfico')}</span>
</div>
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
<span className="text-sm text-[var(--text-secondary)]">{t('landing:local.traffic.features.patterns', 'Patrones de movilidad por ciudad')}</span>
</div>
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
<span className="text-sm text-[var(--text-secondary)]">{t('landing:local.traffic.features.local', 'Adaptado a cada ciudad española')}</span>
</div>
</div>
</div>
{/* Events Data */}
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg border-2 border-amber-200 dark:border-amber-800 hover:border-amber-400 dark:hover:border-amber-600 transition-all duration-300">
<div className="w-16 h-16 bg-gradient-to-br from-amber-500 to-orange-600 rounded-2xl flex items-center justify-center mb-6 mx-auto">
<Calendar className="w-8 h-8 text-white" />
</div>
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-4 text-center">{t('landing:local.events.title', 'Eventos y Festividades')}</h3>
<p className="text-[var(--text-secondary)] text-center mb-6">
{t('landing:local.events.description', 'Integración de festividades locales, nacionales y eventos culturales específicos de cada región para predicciones más precisas.')}
</p>
<div className="space-y-3">
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
<span className="text-sm text-[var(--text-secondary)]">{t('landing:local.events.features.local_holidays', 'Festivos locales y autonómicos')}</span>
</div>
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
<span className="text-sm text-[var(--text-secondary)]">{t('landing:local.events.features.cultural', 'Eventos culturales regionales')}</span>
</div>
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
<span className="text-sm text-[var(--text-secondary)]">{t('landing:local.events.features.scalable', 'Listo para cualquier ciudad española')}</span>
</div>
</div>
</div>
</div>
{/* Spanish Cities Ready */}
<div className="bg-gradient-to-r from-[var(--color-primary)]/10 to-orange-500/10 rounded-2xl p-10 border-2 border-[var(--color-primary)]/30">
<div className="text-center">
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-4">
{t('landing:local.scalability.title', 'Construido para España, Listo para Tu Ciudad')}
</h3>
<p className="text-[var(--text-secondary)] max-w-3xl mx-auto mb-6">
{t('landing:local.scalability.description', 'Aunque comenzamos en Madrid, nuestra arquitectura está diseñada para escalar a cualquier ciudad española manteniendo la misma precisión hiperlocal.')}
</p>
<div className="flex flex-wrap justify-center gap-6 text-sm">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-[var(--color-primary)]" />
<span className="text-[var(--text-secondary)]">{t('landing:local.scalability.madrid', 'Madrid (Lanzamiento)')}</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-[var(--color-success)]" />
<span className="text-[var(--text-secondary)]">{t('landing:local.scalability.scalable', 'Listo para otras ciudades')}</span>
</div>
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-blue-600" />
<span className="text-[var(--text-secondary)]">{t('landing:local.scalability.national', 'Arquitectura nacional')}</span>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Sustainability & SDG Compliance Section */}
<section className="py-24 bg-gradient-to-b from-green-50 to-white dark:from-green-950/20 dark:to-[var(--bg-secondary)]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -608,7 +737,7 @@ const LandingPage: React.FC = () => {
<TreeDeciduous className="w-8 h-8 text-white" />
</div>
<div className="text-center">
<div className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">855 kg</div>
<div className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">85 kg</div>
<div className="text-sm font-semibold text-[var(--text-primary)] mb-2">{t('landing:sustainability.metrics.co2_avoided', 'CO₂ Avoided Monthly')}</div>
<div className="text-xs text-[var(--text-secondary)]">{t('landing:sustainability.metrics.co2_equivalent', 'Equivalent to 43 trees planted')}</div>
</div>
@@ -671,34 +800,34 @@ const LandingPage: React.FC = () => {
</div>
</div>
<div className="flex-1 w-full">
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 shadow-lg">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-semibold text-[var(--text-primary)]">{t('landing:sustainability.sdg.progress_label', 'Progress to Target')}</span>
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 shadow-lg">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-semibold text-[var(--text-primary)]">{t('landing:sustainability.sdg.progress_label', 'Progress to Target')}</span>
<span className="text-2xl font-bold text-green-600">65%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
<div className="bg-gradient-to-r from-green-500 to-emerald-500 h-6 rounded-full flex items-center justify-end pr-3" style={{ width: '65%' }}>
<TrendingUp className="w-4 h-4 text-white" />
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
<div className="bg-gradient-to-r from-green-500 to-emerald-500 h-6 rounded-full flex items-center justify-end pr-3" style={{ width: '65%' }}>
<TrendingUp className="w-4 h-4 text-white" />
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.sdg.baseline', 'Baseline')}</div>
<div className="text-lg font-bold text-[var(--text-primary)]">25%</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.sdg.baseline', 'Baseline')}</div>
<div className="text-lg font-bold text-[var(--text-primary)]">25%</div>
</div>
<div>
<div className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.sdg.current', 'Current')}</div>
<div>
<div className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.sdg.current', 'Current')}</div>
<div className="text-lg font-bold text-green-600">16.25%</div>
</div>
<div>
<div className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.sdg.target', 'Target 2030')}</div>
<div className="text-lg font-bold text-[var(--text-primary)]">12.5%</div>
</div>
</div>
<div>
<div className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.sdg.target', 'Target 2030')}</div>
<div className="text-lg font-bold text-[var(--text-primary)]">12.5%</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Grant Programs Grid */}
<div className="mt-16 grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6">
@@ -784,408 +913,408 @@ const LandingPage: React.FC = () => {
</div>
</section>
{/* Benefits Section - Problem/Solution Focus */}
<section id="benefits" className="py-24 bg-[var(--bg-primary)]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
{t('landing:benefits.title', 'El Problema Que Resolvemos')}
<span className="block text-[var(--color-primary)]">{t('landing:benefits.title_accent', 'Para Panaderías')}</span>
</h2>
<p className="mt-6 text-lg text-[var(--text-secondary)] max-w-3xl mx-auto">
{t('landing:benefits.subtitle', 'Sabemos lo frustrante que es tirar pan al final del día, o quedarte sin producto cuando llegan clientes. La producción artesanal es difícil de optimizar... hasta ahora.')}
</p>
</div>
{/* Benefits Section - Problem/Solution Focus */}
<section id="benefits" className="py-24 bg-[var(--bg-primary)]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
{t('landing:benefits.title', 'El Problema Que Resolvemos')}
<span className="block text-[var(--color-primary)]">{t('landing:benefits.title_accent', 'Para Panaderías')}</span>
</h2>
<p className="mt-6 text-lg text-[var(--text-secondary)] max-w-3xl mx-auto">
{t('landing:benefits.subtitle', 'Sabemos lo frustrante que es tirar pan al final del día, o quedarte sin producto cuando llegan clientes. La producción artesanal es difícil de optimizar... hasta ahora.')}
</p>
</div>
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left: Problems */}
<div className="space-y-6">
<div className="bg-red-50 dark:bg-red-900/10 border-l-4 border-red-500 p-6 rounded-lg">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-red-500 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold text-xl"></span>
</div>
<div>
<h4 className="text-lg font-bold text-red-700 dark:text-red-400 mb-2">{t('landing:benefits.problems.waste.title', 'Desperdicias entre 15-40% de producción')}</h4>
<p className="text-[var(--text-secondary)] text-sm">
{t('landing:benefits.problems.waste.description', 'Al final del día tiras producto que nadie compró. Son cientos de euros a la basura cada semana.')}
</p>
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left: Problems */}
<div className="space-y-6">
<div className="bg-red-50 dark:bg-red-900/10 border-l-4 border-red-500 p-6 rounded-lg">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-red-500 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold text-xl"></span>
</div>
<div>
<h4 className="text-lg font-bold text-red-700 dark:text-red-400 mb-2">{t('landing:benefits.problems.waste.title', 'Desperdicias entre 15-40% de producción')}</h4>
<p className="text-[var(--text-secondary)] text-sm">
{t('landing:benefits.problems.waste.description', 'Al final del día tiras producto que nadie compró. Son cientos de euros a la basura cada semana.')}
</p>
</div>
</div>
</div>
</div>
<div className="bg-red-50 dark:bg-red-900/10 border-l-4 border-red-500 p-6 rounded-lg">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-red-500 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold text-xl"></span>
</div>
<div>
<div className="bg-red-50 dark:bg-red-900/10 border-l-4 border-red-500 p-6 rounded-lg">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-red-500 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold text-xl"></span>
</div>
<div>
<h4 className="text-lg font-bold text-red-700 dark:text-red-400 mb-2">{t('landing:benefits.problems.stockouts.title', 'Pierdes ventas por falta de stock')}</h4>
<p className="text-[var(--text-secondary)] text-sm">
{t('landing:benefits.problems.stockouts.description', 'Clientes que vienen por su pan favorito y se van sin comprar porque ya se te acabó a las 14:00.')}
</p>
<p className="text-[var(--text-secondary)] text-sm">
{t('landing:benefits.problems.stockouts.description', 'Clientes que vienen por su pan favorito y se van sin comprar porque ya se te acabó a las 14:00.')}
</p>
</div>
</div>
</div>
<div className="bg-red-50 dark:bg-red-900/10 border-l-4 border-red-500 p-6 rounded-lg">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-red-500 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold text-xl"></span>
</div>
<div>
<h4 className="text-lg font-bold text-red-700 dark:text-red-400 mb-2">{t('landing:benefits.problems.manual.title', 'Excel, papel y "experiencia"')}</h4>
<p className="text-[var(--text-secondary)] text-sm">
{t('landing:benefits.problems.manual.description', 'Planificas basándote en intuición. Funciona... hasta que no funciona.')}
</p>
</div>
</div>
</div>
</div>
<div className="bg-red-50 dark:bg-red-900/10 border-l-4 border-red-500 p-6 rounded-lg">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-red-500 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold text-xl"></span>
</div>
<div>
<h4 className="text-lg font-bold text-red-700 dark:text-red-400 mb-2">{t('landing:benefits.problems.manual.title', 'Excel, papel y "experiencia"')}</h4>
<p className="text-[var(--text-secondary)] text-sm">
{t('landing:benefits.problems.manual.description', 'Planificas basándote en intuición. Funciona... hasta que no funciona.')}
</p>
{/* Right: Solutions */}
<div className="space-y-6">
<div className="bg-green-50 dark:bg-green-900/10 border-l-4 border-green-500 p-6 rounded-lg">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0">
<Check className="text-white w-6 h-6" />
</div>
<div>
<h4 className="text-lg font-bold text-green-700 dark:text-green-400 mb-2">{t('landing:benefits.solutions.exact_production.title', 'Produce exactamente lo que vas a vender')}</h4>
<p className="text-[var(--text-secondary)] text-sm">
{t('landing:benefits.solutions.exact_production.description', 'La IA analiza tus ventas históricas, clima, eventos locales y festivos para predecir demanda real.')}
</p>
</div>
</div>
</div>
</div>
</div>
{/* Right: Solutions */}
<div className="space-y-6">
<div className="bg-green-50 dark:bg-green-900/10 border-l-4 border-green-500 p-6 rounded-lg">
<div className="flex items-start gap-4">
<div className="bg-green-50 dark:bg-green-900/10 border-l-4 border-green-500 p-6 rounded-lg">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0">
<Check className="text-white w-6 h-6" />
</div>
<div>
<h4 className="text-lg font-bold text-green-700 dark:text-green-400 mb-2">{t('landing:benefits.solutions.exact_production.title', 'Produce exactamente lo que vas a vender')}</h4>
<p className="text-[var(--text-secondary)] text-sm">
{t('landing:benefits.solutions.exact_production.description', 'La IA analiza tus ventas históricas, clima, eventos locales y festivos para predecir demanda real.')}
</p>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/10 border-l-4 border-green-500 p-6 rounded-lg">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0">
<Check className="text-white w-6 h-6" />
</div>
<div>
<Check className="text-white w-6 h-6" />
</div>
<div>
<h4 className="text-lg font-bold text-green-700 dark:text-green-400 mb-2">{t('landing:benefits.solutions.stock_availability.title', 'Siempre tienes stock de lo que más se vende')}</h4>
<p className="text-[var(--text-secondary)] text-sm">
{t('landing:benefits.solutions.stock_availability.description', 'El sistema te avisa qué productos van a tener más demanda cada día, para que nunca te quedes sin.')}
</p>
<p className="text-[var(--text-secondary)] text-sm">
{t('landing:benefits.solutions.stock_availability.description', 'El sistema te avisa qué productos van a tener más demanda cada día, para que nunca te quedes sin.')}
</p>
</div>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/10 border-l-4 border-green-500 p-6 rounded-lg">
<div className="flex items-start gap-4">
<div className="bg-green-50 dark:bg-green-900/10 border-l-4 border-green-500 p-6 rounded-lg">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0">
<Check className="text-white w-6 h-6" />
<Check className="text-white w-6 h-6" />
</div>
<div>
<h4 className="text-lg font-bold text-green-700 dark:text-green-400 mb-2">{t('landing:benefits.solutions.smart_automation.title', 'Automatización inteligente + datos reales')}</h4>
<p className="text-[var(--text-secondary)] text-sm">
{t('landing:benefits.solutions.smart_automation.description', 'Desde planificación de producción hasta gestión de inventario. Todo basado en matemáticas, no corazonadas.')}
</p>
</div>
</div>
<div>
<h4 className="text-lg font-bold text-green-700 dark:text-green-400 mb-2">{t('landing:benefits.solutions.smart_automation.title', 'Automatización inteligente + datos reales')}</h4>
<p className="text-[var(--text-secondary)] text-sm">
{t('landing:benefits.solutions.smart_automation.description', 'Desde planificación de producción hasta gestión de inventario. Todo basado en matemáticas, no corazonadas.')}
</p>
</div>
</div>
</div>
{/* Value Proposition Summary */}
<div className="mt-16 bg-gradient-to-r from-[var(--color-primary)]/10 to-orange-500/10 rounded-2xl p-8 border-2 border-[var(--color-primary)]/30">
<div className="text-center">
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-4">
{t('landing:benefits.value_proposition.title', 'El Objetivo: Que Ahorres Dinero Desde el Primer Mes')}
</h3>
<p className="text-[var(--text-secondary)] max-w-3xl mx-auto mb-6" dangerouslySetInnerHTML={{ __html: t('landing:benefits.value_proposition.description', 'No prometemos números mágicos porque cada panadería es diferente. Lo que SÍ prometemos es que si después de 3 meses no has reducido desperdicios o mejorado tus márgenes, <strong>te ayudamos gratis a optimizar tu negocio de otra forma</strong>.') }} />
<div className="flex flex-wrap justify-center gap-6 text-sm">
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[var(--color-success)]" />
<span className="text-[var(--text-secondary)]">{t('landing:benefits.value_proposition.points.waste', 'Menos desperdicio = más beneficio')}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-blue-600" />
<span className="text-[var(--text-secondary)]">{t('landing:benefits.value_proposition.points.time', 'Menos tiempo en Excel, más en tu negocio')}</span>
</div>
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-purple-600" />
<span className="text-[var(--text-secondary)]">{t('landing:benefits.value_proposition.points.data', 'Tus datos siempre son tuyos')}</span>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Value Proposition Summary */}
<div className="mt-16 bg-gradient-to-r from-[var(--color-primary)]/10 to-orange-500/10 rounded-2xl p-8 border-2 border-[var(--color-primary)]/30">
<div className="text-center">
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-4">
{t('landing:benefits.value_proposition.title', 'El Objetivo: Que Ahorres Dinero Desde el Primer Mes')}
</h3>
<p className="text-[var(--text-secondary)] max-w-3xl mx-auto mb-6" dangerouslySetInnerHTML={{ __html: t('landing:benefits.value_proposition.description', 'No prometemos números mágicos porque cada panadería es diferente. Lo que SÍ prometemos es que si después de 3 meses no has reducido desperdicios o mejorado tus márgenes, <strong>te ayudamos gratis a optimizar tu negocio de otra forma</strong>.') }} />
<div className="flex flex-wrap justify-center gap-6 text-sm">
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[var(--color-success)]" />
<span className="text-[var(--text-secondary)]">{t('landing:benefits.value_proposition.points.waste', 'Menos desperdicio = más beneficio')}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-blue-600" />
<span className="text-[var(--text-secondary)]">{t('landing:benefits.value_proposition.points.time', 'Menos tiempo en Excel, más en tu negocio')}</span>
</div>
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-purple-600" />
<span className="text-[var(--text-secondary)]">{t('landing:benefits.value_proposition.points.data', 'Tus datos siempre son tuyos')}</span>
</div>
{/* Risk Reversal & Transparency Section */}
<section id="testimonials" className="py-24 bg-[var(--bg-secondary)]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
{t('landing:risk_reversal.title', 'Sin Riesgo. Sin Ataduras.')}
</h2>
<p className="mt-4 max-w-2xl mx-auto text-lg text-[var(--text-secondary)]">
{t('landing:risk_reversal.subtitle', 'Somos transparentes: esto es un piloto. Estamos construyendo la mejor herramienta para panaderías, y necesitamos tu ayuda.')}
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-16">
{/* Left: What You Get */}
<div className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-2xl p-8 border-2 border-green-300 dark:border-green-700">
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-6 flex items-center gap-3">
<div className="w-10 h-10 bg-green-600 rounded-full flex items-center justify-center">
<Check className="w-6 h-6 text-white" />
</div>
{t('landing:risk_reversal.what_you_get.title', 'Lo Que Obtienes')}
</h3>
<ul className="space-y-4">
<li className="flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_you_get.free_trial', '<strong>3 meses completamente gratis</strong> para probar todas las funcionalidades') }} />
</li>
<li className="flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_you_get.lifetime_discount', '<strong>20% de descuento de por vida</strong> si decides continuar después del piloto') }} />
</li>
<li className="flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_you_get.founder_support', '<strong>Soporte directo del equipo fundador</strong> - respondemos en horas, no días') }} />
</li>
<li className="flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_you_get.priority_features', '<strong>Tus ideas se implementan primero</strong> - construimos lo que realmente necesitas') }} />
</li>
<li className="flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_you_get.cancel_anytime', '<strong>Cancelas cuando quieras</strong> sin explicaciones ni penalizaciones') }} />
</li>
</ul>
</div>
</div>
</div>
</div>
</section>
{/* Risk Reversal & Transparency Section */}
<section id="testimonials" className="py-24 bg-[var(--bg-secondary)]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
{t('landing:risk_reversal.title', 'Sin Riesgo. Sin Ataduras.')}
</h2>
<p className="mt-4 max-w-2xl mx-auto text-lg text-[var(--text-secondary)]">
{t('landing:risk_reversal.subtitle', 'Somos transparentes: esto es un piloto. Estamos construyendo la mejor herramienta para panaderías, y necesitamos tu ayuda.')}
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-16">
{/* Left: What You Get */}
<div className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-2xl p-8 border-2 border-green-300 dark:border-green-700">
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-6 flex items-center gap-3">
<div className="w-10 h-10 bg-green-600 rounded-full flex items-center justify-center">
<Check className="w-6 h-6 text-white" />
</div>
{t('landing:risk_reversal.what_you_get.title', 'Lo Que Obtienes')}
</h3>
<ul className="space-y-4">
<li className="flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_you_get.free_trial', '<strong>3 meses completamente gratis</strong> para probar todas las funcionalidades') }} />
</li>
<li className="flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_you_get.lifetime_discount', '<strong>20% de descuento de por vida</strong> si decides continuar después del piloto') }} />
</li>
<li className="flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_you_get.founder_support', '<strong>Soporte directo del equipo fundador</strong> - respondemos en horas, no días') }} />
</li>
<li className="flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_you_get.priority_features', '<strong>Tus ideas se implementan primero</strong> - construimos lo que realmente necesitas') }} />
</li>
<li className="flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_you_get.cancel_anytime', '<strong>Cancelas cuando quieras</strong> sin explicaciones ni penalizaciones') }} />
</li>
</ul>
</div>
{/* Right: What We Ask */}
<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-300 dark:border-blue-700">
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-6 flex items-center gap-3">
{/* Right: What We Ask */}
<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-300 dark:border-blue-700">
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-6 flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center">
<Users className="w-6 h-6 text-white" />
</div>
{t('landing:risk_reversal.what_we_ask.title', 'Lo Que Pedimos')}
</h3>
<ul className="space-y-4">
<li className="flex items-start gap-3">
<ArrowRight className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_we_ask.feedback', '<strong>Feedback honesto semanal</strong> (15 min) sobre qué funciona y qué no') }} />
</li>
<li className="flex items-start gap-3">
<ArrowRight className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_we_ask.patience', '<strong>Paciencia con bugs</strong> - estamos en fase beta, habrá imperfecciones') }} />
</li>
<li className="flex items-start gap-3">
<ArrowRight className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_we_ask.data', '<strong>Datos de ventas históricos</strong> (opcional) para mejorar las predicciones') }} />
</li>
<li className="flex items-start gap-3">
<ArrowRight className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_we_ask.communication', '<strong>Comunicación abierta</strong> - queremos saber si algo no te gusta') }} />
</li>
</ul>
<Users className="w-6 h-6 text-white" />
</div>
{t('landing:risk_reversal.what_we_ask.title', 'Lo Que Pedimos')}
</h3>
<ul className="space-y-4">
<li className="flex items-start gap-3">
<ArrowRight className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_we_ask.feedback', '<strong>Feedback honesto semanal</strong> (15 min) sobre qué funciona y qué no') }} />
</li>
<li className="flex items-start gap-3">
<ArrowRight className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_we_ask.patience', '<strong>Paciencia con bugs</strong> - estamos en fase beta, habrá imperfecciones') }} />
</li>
<li className="flex items-start gap-3">
<ArrowRight className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_we_ask.data', '<strong>Datos de ventas históricos</strong> (opcional) para mejorar las predicciones') }} />
</li>
<li className="flex items-start gap-3">
<ArrowRight className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-1 flex-shrink-0" />
<span className="text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.what_we_ask.communication', '<strong>Comunicación abierta</strong> - queremos saber si algo no te gusta') }} />
</li>
</ul>
<div className="mt-6 p-4 bg-white dark:bg-gray-800 rounded-lg border border-blue-200 dark:border-blue-800">
<p className="text-sm text-[var(--text-secondary)] italic" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.promise', '<strong>Promesa:</strong> Si después de 3 meses sientes que no te ayudamos a ahorrar dinero o reducir desperdicios, te damos una sesión gratuita de consultoría para optimizar tu panadería de otra forma.') }} />
<div className="mt-6 p-4 bg-white dark:bg-gray-800 rounded-lg border border-blue-200 dark:border-blue-800">
<p className="text-sm text-[var(--text-secondary)] italic" dangerouslySetInnerHTML={{ __html: t('landing:risk_reversal.promise', '<strong>Promesa:</strong> Si después de 3 meses sientes que no te ayudamos a ahorrar dinero o reducir desperdicios, te damos una sesión gratuita de consultoría para optimizar tu panadería de otra forma.') }} />
</div>
</div>
</div>
{/* Credibility Signals */}
<div className="bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg border border-[var(--border-primary)]">
<div className="text-center mb-8">
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-3">
{t('landing:risk_reversal.credibility.title', '¿Por Qué Confiar en Nosotros?')}
</h3>
<p className="text-[var(--text-secondary)]">
{t('landing:risk_reversal.credibility.subtitle', 'Entendemos que probar nueva tecnología es un riesgo. Por eso somos completamente transparentes:')}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<div className="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-purple-600 dark:text-purple-400" />
</div>
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{t('landing:risk_reversal.credibility.spanish.title', '100% Española')}</h4>
<p className="text-sm text-[var(--text-secondary)]">
{t('landing:risk_reversal.credibility.spanish.description', 'Empresa registrada en España. Tus datos están protegidos por RGPD y nunca salen de la UE.')}
</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Brain className="w-8 h-8 text-orange-600 dark:text-orange-400" />
</div>
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{t('landing:risk_reversal.credibility.technology.title', 'Tecnología Probada')}</h4>
<p className="text-sm text-[var(--text-secondary)]">
{t('landing:risk_reversal.credibility.technology.description', 'Usamos algoritmos de IA validados académicamente, adaptados específicamente para panaderías.')}
</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-teal-100 dark:bg-teal-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Award className="w-8 h-8 text-teal-600 dark:text-teal-400" />
</div>
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{t('landing:risk_reversal.credibility.team.title', 'Equipo Experto')}</h4>
<p className="text-sm text-[var(--text-secondary)]">
{t('landing:risk_reversal.credibility.team.description', 'Fundadores con experiencia en proyectos de alto valor tecnológico + proyectos internacionales.')}
</p>
</div>
</div>
</div>
</div>
</section>
{/* Credibility Signals */}
<div className="bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg border border-[var(--border-primary)]">
<div className="text-center mb-8">
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-3">
{t('landing:risk_reversal.credibility.title', '¿Por Qué Confiar en Nosotros?')}
</h3>
<p className="text-[var(--text-secondary)]">
{t('landing:risk_reversal.credibility.subtitle', 'Entendemos que probar nueva tecnología es un riesgo. Por eso somos completamente transparentes:')}
{/* Pricing Section */}
<section id="pricing">
<PricingSection />
</section>
{/* FAQ Section */}
<section id="faq" className="py-24 bg-[var(--bg-secondary)]">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
{t('landing:faq.title', 'Preguntas Frecuentes')}
</h2>
<p className="mt-4 text-lg text-[var(--text-secondary)]">
{t('landing:faq.subtitle', 'Todo lo que necesitas saber sobre Panadería IA')}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<div className="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-purple-600 dark:text-purple-400" />
</div>
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{t('landing:risk_reversal.credibility.spanish.title', '100% Española')}</h4>
<p className="text-sm text-[var(--text-secondary)]">
{t('landing:risk_reversal.credibility.spanish.description', 'Empresa registrada en España. Tus datos están protegidos por RGPD y nunca salen de la UE.')}
</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Brain className="w-8 h-8 text-orange-600 dark:text-orange-400" />
</div>
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{t('landing:risk_reversal.credibility.technology.title', 'Tecnología Probada')}</h4>
<p className="text-sm text-[var(--text-secondary)]">
{t('landing:risk_reversal.credibility.technology.description', 'Usamos algoritmos de IA validados académicamente, adaptados específicamente para panaderías.')}
</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-teal-100 dark:bg-teal-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Award className="w-8 h-8 text-teal-600 dark:text-teal-400" />
</div>
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{t('landing:risk_reversal.credibility.team.title', 'Equipo Experto')}</h4>
<p className="text-sm text-[var(--text-secondary)]">
{t('landing:risk_reversal.credibility.team.description', 'Fundadores con experiencia en proyectos de alto valor tecnológico + proyectos internacionales.')}
</p>
</div>
</div>
</div>
</div>
</section>
{/* Pricing Section */}
<section id="pricing">
<PricingSection />
</section>
{/* FAQ Section */}
<section id="faq" className="py-24 bg-[var(--bg-secondary)]">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
{t('landing:faq.title', 'Preguntas Frecuentes')}
</h2>
<p className="mt-4 text-lg text-[var(--text-secondary)]">
{t('landing:faq.subtitle', 'Todo lo que necesitas saber sobre Panadería IA')}
</p>
</div>
<div className="mt-16 space-y-8">
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
<div className="mt-16 space-y-8">
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('landing:faq.questions.accuracy.q', '¿Qué tan precisa es la predicción de demanda?')}
</h3>
<p className="mt-4 text-[var(--text-secondary)]">
{t('landing:faq.questions.accuracy.a', 'Nuestra IA alcanza una precisión del 92% en predicciones de demanda, analizando más de 50 variables incluyendo histórico de ventas, clima, eventos locales, estacionalidad y tendencias de mercado. La precisión mejora continuamente con más datos de tu panadería.')}
</p>
</div>
</h3>
<p className="mt-4 text-[var(--text-secondary)]">
{t('landing:faq.questions.accuracy.a', 'Nuestra IA alcanza una precisión del 92% en predicciones de demanda, analizando más de 50 variables incluyendo histórico de ventas, clima, eventos locales, estacionalidad y tendencias de mercado. La precisión mejora continuamente con más datos de tu panadería.')}
</p>
</div>
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('landing:faq.questions.implementation.q', '¿Cuánto tiempo toma implementar el sistema?')}
</h3>
<p className="mt-4 text-[var(--text-secondary)]">
{t('landing:faq.questions.implementation.a', 'La configuración inicial toma solo 5 minutos. Nuestro equipo te ayuda a migrar tus datos históricos en 24-48 horas. La IA comienza a generar predicciones útiles después de una semana de datos, alcanzando máxima precisión en 30 días.')}
</p>
</div>
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('landing:faq.questions.implementation.q', '¿Cuánto tiempo toma implementar el sistema?')}
</h3>
<p className="mt-4 text-[var(--text-secondary)]">
{t('landing:faq.questions.implementation.a', 'La configuración inicial toma solo 5 minutos. Nuestro equipo te ayuda a migrar tus datos históricos en 24-48 horas. La IA comienza a generar predicciones útiles después de una semana de datos, alcanzando máxima precisión en 30 días.')}
</p>
</div>
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('landing:faq.questions.integration.q', '¿Se integra con mi sistema POS actual?')}
</h3>
<p className="mt-4 text-[var(--text-secondary)]">
{t('landing:faq.questions.integration.a', 'Sí, nos integramos con más de 50 sistemas POS populares en España. También incluimos nuestro propio POS optimizado para panaderías. Si usas un sistema específico, nuestro equipo técnico puede crear una integración personalizada.')}
</p>
</div>
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('landing:faq.questions.integration.q', '¿Se integra con mi sistema POS actual?')}
</h3>
<p className="mt-4 text-[var(--text-secondary)]">
{t('landing:faq.questions.integration.a', 'Sí, nos integramos con más de 50 sistemas POS populares en España. También incluimos nuestro propio POS optimizado para panaderías. Si usas un sistema específico, nuestro equipo técnico puede crear una integración personalizada.')}
</p>
</div>
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('landing:faq.questions.support.q', '¿Qué soporte técnico ofrecen?')}
</h3>
<p className="mt-4 text-[var(--text-secondary)]">
{t('landing:faq.questions.support.a', 'Ofrecemos soporte 24/7 en español por chat, email y teléfono. Todos nuestros técnicos son expertos en operaciones de panadería. Además, incluimos onboarding personalizado y training para tu equipo sin costo adicional.')}
</p>
</div>
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('landing:faq.questions.support.q', '¿Qué soporte técnico ofrecen?')}
</h3>
<p className="mt-4 text-[var(--text-secondary)]">
{t('landing:faq.questions.support.a', 'Ofrecemos soporte 24/7 en español por chat, email y teléfono. Todos nuestros técnicos son expertos en operaciones de panadería. Además, incluimos onboarding personalizado y training para tu equipo sin costo adicional.')}
</p>
</div>
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('landing:faq.questions.security.q', '¿Mis datos están seguros?')}
</h3>
<p className="mt-4 text-[var(--text-secondary)]">
{t('landing:faq.questions.security.a', 'Absolutamente. Utilizamos cifrado AES-256, servidores en la UE, cumplimos 100% con RGPD y realizamos auditorías de seguridad trimestrales. Tus datos nunca se comparten con terceros y tienes control total sobre tu información.')}
</p>
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('landing:faq.questions.security.q', '¿Mis datos están seguros?')}
</h3>
<p className="mt-4 text-[var(--text-secondary)]">
{t('landing:faq.questions.security.a', 'Absolutamente. Utilizamos cifrado AES-256, servidores en la UE, cumplimos 100% con RGPD y realizamos auditorías de seguridad trimestrales. Tus datos nunca se comparten con terceros y tienes control total sobre tu información.')}
</p>
</div>
</div>
</div>
</div>
</section>
</section>
{/* Final CTA Section - With Urgency & Scarcity */}
<section className="py-24 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] relative overflow-hidden">
<div className="absolute inset-0">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
</div>
<div className="max-w-4xl mx-auto text-center px-4 sm:px-6 lg:px-8 relative">
{/* Scarcity Badge */}
<div className="inline-flex items-center gap-2 bg-red-600 text-white px-6 py-3 rounded-full text-sm font-bold mb-6 shadow-lg animate-pulse">
<Clock className="w-5 h-5" />
<span>{t('landing:final_cta.scarcity_badge', 'Quedan 12 plazas de las 20 del programa piloto')}</span>
{/* Final CTA Section - With Urgency & Scarcity */}
<section className="py-24 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] relative overflow-hidden">
<div className="absolute inset-0">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
</div>
<h2 className="text-3xl lg:text-5xl font-extrabold text-white">
{t('landing:final_cta.title', 'Sé de las Primeras 20 Panaderías')}
<span className="block text-white/90 mt-2">{t('landing:final_cta.title_accent', 'En Probar Esta Tecnología')}</span>
</h2>
<p className="mt-6 text-lg text-white/90 max-w-2xl mx-auto" dangerouslySetInnerHTML={{ __html: t('landing:final_cta.subtitle', 'No es para todo el mundo. Buscamos panaderías que quieran <strong>reducir desperdicios y aumentar ganancias</strong> con ayuda de IA, a cambio de feedback honesto.') }} />
<div className="max-w-4xl mx-auto text-center px-4 sm:px-6 lg:px-8 relative">
{/* Scarcity Badge */}
<div className="inline-flex items-center gap-2 bg-red-600 text-white px-6 py-3 rounded-full text-sm font-bold mb-6 shadow-lg animate-pulse">
<Clock className="w-5 h-5" />
<span>{t('landing:final_cta.scarcity_badge', 'Quedan 12 plazas de las 20 del programa piloto')}</span>
</div>
<h2 className="text-3xl lg:text-5xl font-extrabold text-white">
{t('landing:final_cta.title', 'Sé de las Primeras 20 Panaderías')}
<span className="block text-white/90 mt-2">{t('landing:final_cta.title_accent', 'En Probar Esta Tecnología')}</span>
</h2>
<p className="mt-6 text-lg text-white/90 max-w-2xl mx-auto" dangerouslySetInnerHTML={{ __html: t('landing:final_cta.subtitle', 'No es para todo el mundo. Buscamos panaderías que quieran <strong>reducir desperdicios y aumentar ganancias</strong> con ayuda de IA, a cambio de feedback honesto.') }} />
<div className="mt-10 flex flex-col sm:flex-row gap-6 justify-center">
<Link to={getRegisterUrl()} className="w-full sm:w-auto">
<Button
size="lg"
className="w-full sm:w-auto group relative px-10 py-5 text-lg font-bold bg-gradient-to-r from-[var(--color-primary)] to-orange-600 hover:from-[var(--color-primary-dark)] hover:to-orange-700 text-white shadow-2xl hover:shadow-3xl transform hover:scale-105 transition-all duration-300 rounded-xl overflow-hidden"
>
<span className="absolute inset-0 w-full h-full bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
<span className="relative flex items-center justify-center gap-2">
{t('landing:final_cta.cta_primary', 'Solicitar Plaza en el Piloto')}
<ArrowRight className="w-6 h-6 group-hover:translate-x-1 transition-transform duration-200" />
</span>
</Button>
</Link>
<Link to={getDemoUrl()} className="w-full sm:w-auto">
<Button
variant="outline"
size="lg"
<Link to={getRegisterUrl()} className="w-full sm:w-auto">
<Button
size="lg"
className="w-full sm:w-auto group relative px-10 py-5 text-lg font-bold bg-gradient-to-r from-[var(--color-primary)] to-orange-600 hover:from-[var(--color-primary-dark)] hover:to-orange-700 text-white shadow-2xl hover:shadow-3xl transform hover:scale-105 transition-all duration-300 rounded-xl overflow-hidden"
>
<span className="absolute inset-0 w-full h-full bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
<span className="relative flex items-center justify-center gap-2">
{t('landing:final_cta.cta_primary', 'Solicitar Plaza en el Piloto')}
<ArrowRight className="w-6 h-6 group-hover:translate-x-1 transition-transform duration-200" />
</span>
</Button>
</Link>
<Link to={getDemoUrl()} className="w-full sm:w-auto">
<Button
variant="outline"
size="lg"
className="w-full sm:w-auto group px-10 py-5 text-lg font-semibold border-3 border-[var(--color-primary)] text-[var(--text-primary)] hover:bg-[var(--color-primary)] hover:text-white hover:border-[var(--color-primary-dark)] shadow-lg hover:shadow-xl transition-all duration-300 rounded-xl backdrop-blur-sm bg-white/50 dark:bg-gray-800/50"
>
<span className="flex items-center justify-center gap-2">
<Play className="w-5 h-5 group-hover:scale-110 transition-transform duration-200" />
{t('landing:hero.cta_secondary', 'Ver Demo')}
</span>
</Button>
</Link>
</div>
>
<span className="flex items-center justify-center gap-2">
<Play className="w-5 h-5 group-hover:scale-110 transition-transform duration-200" />
{t('landing:hero.cta_secondary', 'Ver Demo')}
</span>
</Button>
</Link>
</div>
{/* Social Proof Alternative - Loss Aversion */}
<div className="mt-12 bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<p className="text-white/90 text-base mb-4">
<strong>{t('landing:final_cta.why_now.title', '¿Por qué actuar ahora?')}</strong>
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 text-sm">
<div className="flex flex-col items-center">
<Award className="w-8 h-8 text-white mb-2" />
<div className="text-white font-semibold">{t('landing:final_cta.why_now.lifetime_discount.title', '20% descuento de por vida')}</div>
<div className="text-white/70">{t('landing:final_cta.why_now.lifetime_discount.subtitle', 'Solo primeros 20')}</div>
</div>
<div className="flex flex-col items-center">
<Users className="w-8 h-8 text-white mb-2" />
<div className="text-white font-semibold">{t('landing:final_cta.why_now.influence.title', 'Influyes en el roadmap')}</div>
<div className="text-white/70">{t('landing:final_cta.why_now.influence.subtitle', 'Tus necesidades primero')}</div>
</div>
<div className="flex flex-col items-center">
<Zap className="w-8 h-8 text-white mb-2" />
<div className="text-white font-semibold">{t('landing:final_cta.why_now.vip_support.title', 'Soporte VIP')}</div>
<div className="text-white/70">{t('landing:final_cta.why_now.vip_support.subtitle', 'Acceso directo al equipo')}</div>
{/* Social Proof Alternative - Loss Aversion */}
<div className="mt-12 bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<p className="text-white/90 text-base mb-4">
<strong>{t('landing:final_cta.why_now.title', '¿Por qué actuar ahora?')}</strong>
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 text-sm">
<div className="flex flex-col items-center">
<Award className="w-8 h-8 text-white mb-2" />
<div className="text-white font-semibold">{t('landing:final_cta.why_now.lifetime_discount.title', '20% descuento de por vida')}</div>
<div className="text-white/70">{t('landing:final_cta.why_now.lifetime_discount.subtitle', 'Solo primeros 20')}</div>
</div>
<div className="flex flex-col items-center">
<Users className="w-8 h-8 text-white mb-2" />
<div className="text-white font-semibold">{t('landing:final_cta.why_now.influence.title', 'Influyes en el roadmap')}</div>
<div className="text-white/70">{t('landing:final_cta.why_now.influence.subtitle', 'Tus necesidades primero')}</div>
</div>
<div className="flex flex-col items-center">
<Zap className="w-8 h-8 text-white mb-2" />
<div className="text-white font-semibold">{t('landing:final_cta.why_now.vip_support.title', 'Soporte VIP')}</div>
<div className="text-white/70">{t('landing:final_cta.why_now.vip_support.subtitle', 'Acceso directo al equipo')}</div>
</div>
</div>
</div>
{/* Guarantee */}
<div className="mt-8 text-white/80 text-sm">
<Shield className="w-5 h-5 inline mr-2" />
<span>{t('landing:final_cta.guarantee', 'Garantía: Cancelas en cualquier momento sin dar explicaciones')}</span>
</div>
</div>
</section>
{/* Guarantee */}
<div className="mt-8 text-white/80 text-sm">
<Shield className="w-5 h-5 inline mr-2" />
<span>{t('landing:final_cta.guarantee', 'Garantía: Cancelas en cualquier momento sin dar explicaciones')}</span>
</div>
</div>
</section>
</PublicLayout>
);
};
</PublicLayout>
);
};
export default LandingPage;
export default LandingPage;

View File

@@ -6,6 +6,9 @@ export type Language = 'es' | 'en' | 'eu';
export type ViewMode = 'list' | 'grid' | 'card';
export type SidebarState = 'expanded' | 'collapsed' | 'hidden';
// Toast interface kept for backward compatibility but toast functionality
// has been moved to src/utils/toast.ts using react-hot-toast
// This interface is deprecated and will be removed in a future version
export interface Toast {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
@@ -45,10 +48,7 @@ export interface UIState {
// Loading States
globalLoading: boolean;
loadingStates: Record<string, boolean>;
// Toasts & Notifications
toasts: Toast[];
// Modals & Dialogs
modals: Modal[];
@@ -77,11 +77,7 @@ export interface UIState {
setGlobalLoading: (loading: boolean) => void;
setLoading: (key: string, loading: boolean) => void;
isLoading: (key: string) => boolean;
showToast: (toast: Omit<Toast, 'id'>) => string;
hideToast: (id: string) => void;
clearToasts: () => void;
showModal: (modal: Omit<Modal, 'id'>) => string;
hideModal: (id: string) => void;
clearModals: () => void;
@@ -119,8 +115,7 @@ export const useUIStore = create<UIState>()(
globalLoading: false,
loadingStates: {},
toasts: [],
modals: [],
preferences: defaultPreferences,
@@ -211,39 +206,6 @@ export const useUIStore = create<UIState>()(
return get().loadingStates[key] ?? false;
},
// Toast actions
showToast: (toast: Omit<Toast, 'id'>): string => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newToast: Toast = {
...toast,
id,
duration: toast.duration ?? (toast.type === 'error' ? 0 : 5000), // Error toasts don't auto-dismiss
};
set((state) => ({
toasts: [...state.toasts, newToast],
}));
// Auto-dismiss toast if duration is set
if (newToast.duration && newToast.duration > 0) {
setTimeout(() => {
get().hideToast(id);
}, newToast.duration);
}
return id;
},
hideToast: (id: string) => {
set((state) => ({
toasts: state.toasts.filter(toast => toast.id !== id),
}));
},
clearToasts: () => {
set({ toasts: [] });
},
// Modal actions
showModal: (modal: Omit<Modal, 'id'>): string => {
const id = `modal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -336,7 +298,6 @@ export const useLoading = (key?: string) => {
return useUIStore((state) => state.globalLoading);
};
export const useToasts = () => useUIStore((state) => state.toasts);
export const useModals = () => useUIStore((state) => state.modals);
export const useBreadcrumbs = () => useUIStore((state) => ({
@@ -358,9 +319,6 @@ export const useUIActions = () => useUIStore((state) => ({
setViewMode: state.setViewMode,
setGlobalLoading: state.setGlobalLoading,
setLoading: state.setLoading,
showToast: state.showToast,
hideToast: state.hideToast,
clearToasts: state.clearToasts,
showModal: state.showModal,
hideModal: state.hideModal,
clearModals: state.clearModals,

192
frontend/src/utils/toast.ts Normal file
View File

@@ -0,0 +1,192 @@
import toast from 'react-hot-toast';
/**
* Centralized toast notification utility
* Wraps react-hot-toast with consistent API and standardized behavior
*/
export interface ToastOptions {
/** Optional title for the toast (displayed above message) */
title?: string;
/** Custom duration in milliseconds (overrides default) */
duration?: number;
/** Toast ID for managing specific toasts */
id?: string;
}
const DEFAULT_DURATIONS = {
success: 4000,
error: 6000,
warning: 5000,
info: 4000,
loading: 0, // infinite until dismissed
} as const;
/**
* Show a success toast notification
* @param message - The message to display (can be translation key or direct string)
* @param options - Optional configuration
*/
const success = (message: string, options?: ToastOptions): string => {
const duration = options?.duration ?? DEFAULT_DURATIONS.success;
const fullMessage = options?.title
? `${options.title}\n${message}`
: message;
return toast.success(fullMessage, {
duration,
id: options?.id,
});
};
/**
* Show an error toast notification
* @param message - The error message to display
* @param options - Optional configuration
*/
const error = (message: string, options?: ToastOptions): string => {
const duration = options?.duration ?? DEFAULT_DURATIONS.error;
const fullMessage = options?.title
? `${options.title}\n${message}`
: message;
return toast.error(fullMessage, {
duration,
id: options?.id,
});
};
/**
* Show a warning toast notification
* @param message - The warning message to display
* @param options - Optional configuration
*/
const warning = (message: string, options?: ToastOptions): string => {
const duration = options?.duration ?? DEFAULT_DURATIONS.warning;
const fullMessage = options?.title
? `${options.title}\n${message}`
: message;
return toast(fullMessage, {
duration,
id: options?.id,
icon: '⚠️',
});
};
/**
* Show an info toast notification
* @param message - The info message to display
* @param options - Optional configuration
*/
const info = (message: string, options?: ToastOptions): string => {
const duration = options?.duration ?? DEFAULT_DURATIONS.info;
const fullMessage = options?.title
? `${options.title}\n${message}`
: message;
return toast(fullMessage, {
duration,
id: options?.id,
icon: '',
});
};
/**
* Show a loading toast notification
* @param message - The loading message to display
* @param options - Optional configuration
*/
const loading = (message: string, options?: ToastOptions): string => {
const duration = options?.duration ?? DEFAULT_DURATIONS.loading;
const fullMessage = options?.title
? `${options.title}\n${message}`
: message;
return toast.loading(fullMessage, {
duration,
id: options?.id,
});
};
/**
* Dismiss a specific toast by ID
* @param toastId - The ID of the toast to dismiss
*/
const dismiss = (toastId?: string): void => {
toast.dismiss(toastId);
};
/**
* Show a promise toast that updates based on promise state
* Useful for async operations
*/
const promise = <T,>(
promise: Promise<T>,
messages: {
loading: string;
success: string | ((data: T) => string);
error: string | ((error: Error) => string);
},
options?: ToastOptions
): Promise<T> => {
return toast.promise(
promise,
{
loading: messages.loading,
success: messages.success,
error: messages.error,
},
{
success: {
duration: options?.duration ?? DEFAULT_DURATIONS.success,
},
error: {
duration: options?.duration ?? DEFAULT_DURATIONS.error,
},
}
);
};
/**
* Unified toast notification utility
* Use this instead of importing react-hot-toast directly
*
* @example
* ```typescript
* import { showToast } from '@/utils/toast';
*
* // Simple success
* showToast.success('Operation completed');
*
* // Error with title
* showToast.error('Failed to save', { title: 'Error' });
*
* // Promise-based
* showToast.promise(
* apiCall(),
* {
* loading: 'Saving...',
* success: 'Saved successfully',
* error: 'Failed to save'
* }
* );
* ```
*/
export const showToast = {
success,
error,
warning,
info,
loading,
dismiss,
promise,
};
// Re-export toast for advanced use cases (custom toasts, etc.)
export { toast };