Add role-based filtering and imporve code
This commit is contained in:
@@ -4,11 +4,9 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { subscriptionService } from '../services/subscription';
|
||||
import {
|
||||
SUBSCRIPTION_PLANS,
|
||||
ANALYTICS_LEVELS,
|
||||
AnalyticsLevel,
|
||||
SubscriptionPlanKey
|
||||
import {
|
||||
SUBSCRIPTION_TIERS,
|
||||
SubscriptionTier
|
||||
} from '../types/subscription';
|
||||
import { useCurrentTenant } from '../../stores';
|
||||
import { useAuthUser } from '../../stores/auth.store';
|
||||
@@ -28,7 +26,7 @@ export interface SubscriptionLimits {
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
plan: string;
|
||||
status: 'active' | 'inactive' | 'past_due' | 'cancelled';
|
||||
status: 'active' | 'inactive' | 'past_due' | 'cancelled' | 'trialing';
|
||||
features: Record<string, any>;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
@@ -101,14 +99,14 @@ export const useSubscription = () => {
|
||||
const getAnalyticsAccess = useCallback((): { hasAccess: boolean; level: string; reason?: string } => {
|
||||
const { plan } = subscriptionInfo;
|
||||
|
||||
// Convert plan to typed plan key if it matches our known plans
|
||||
let planKey: keyof typeof SUBSCRIPTION_PLANS | undefined;
|
||||
if (plan === SUBSCRIPTION_PLANS.STARTER) planKey = SUBSCRIPTION_PLANS.STARTER;
|
||||
else if (plan === SUBSCRIPTION_PLANS.PROFESSIONAL) planKey = SUBSCRIPTION_PLANS.PROFESSIONAL;
|
||||
else if (plan === SUBSCRIPTION_PLANS.ENTERPRISE) planKey = SUBSCRIPTION_PLANS.ENTERPRISE;
|
||||
// Convert plan string to typed SubscriptionTier
|
||||
let tierKey: SubscriptionTier | undefined;
|
||||
if (plan === SUBSCRIPTION_TIERS.STARTER) tierKey = SUBSCRIPTION_TIERS.STARTER;
|
||||
else if (plan === SUBSCRIPTION_TIERS.PROFESSIONAL) tierKey = SUBSCRIPTION_TIERS.PROFESSIONAL;
|
||||
else if (plan === SUBSCRIPTION_TIERS.ENTERPRISE) tierKey = SUBSCRIPTION_TIERS.ENTERPRISE;
|
||||
|
||||
if (planKey) {
|
||||
const analyticsLevel = subscriptionService.getAnalyticsLevelForPlan(planKey);
|
||||
if (tierKey) {
|
||||
const analyticsLevel = subscriptionService.getAnalyticsLevelForTier(tierKey);
|
||||
return { hasAccess: true, level: analyticsLevel };
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,16 @@ export type {
|
||||
AvailablePlans,
|
||||
Plan,
|
||||
PlanUpgradeValidation,
|
||||
PlanUpgradeResult
|
||||
PlanUpgradeResult,
|
||||
SubscriptionTier,
|
||||
BillingCycle,
|
||||
PlanMetadata
|
||||
} from './types/subscription';
|
||||
|
||||
export {
|
||||
SUBSCRIPTION_TIERS,
|
||||
BILLING_CYCLES,
|
||||
ANALYTICS_LEVELS
|
||||
} from './types/subscription';
|
||||
|
||||
// Types - Sales
|
||||
|
||||
108
frontend/src/api/services/nominatim.ts
Normal file
108
frontend/src/api/services/nominatim.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Nominatim Geocoding API Service
|
||||
* Provides address search and autocomplete functionality
|
||||
*/
|
||||
|
||||
import apiClient from '../client';
|
||||
|
||||
export interface NominatimResult {
|
||||
place_id: number;
|
||||
lat: string;
|
||||
lon: string;
|
||||
display_name: string;
|
||||
address: {
|
||||
road?: string;
|
||||
house_number?: string;
|
||||
city?: string;
|
||||
town?: string;
|
||||
village?: string;
|
||||
municipality?: string;
|
||||
postcode?: string;
|
||||
country?: string;
|
||||
};
|
||||
boundingbox: [string, string, string, string];
|
||||
}
|
||||
|
||||
export interface NominatimSearchParams {
|
||||
q: string;
|
||||
format?: 'json';
|
||||
addressdetails?: 1 | 0;
|
||||
limit?: number;
|
||||
countrycodes?: string;
|
||||
}
|
||||
|
||||
class NominatimService {
|
||||
private baseUrl = '/api/v1/nominatim';
|
||||
|
||||
/**
|
||||
* Search for addresses matching a query
|
||||
*/
|
||||
async searchAddress(query: string, limit: number = 5): Promise<NominatimResult[]> {
|
||||
if (!query || query.length < 3) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<NominatimResult[]>(`${this.baseUrl}/search`, {
|
||||
params: {
|
||||
q: query,
|
||||
format: 'json',
|
||||
addressdetails: 1,
|
||||
limit,
|
||||
countrycodes: 'es', // Spain only
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Address search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Nominatim result for display
|
||||
*/
|
||||
formatAddress(result: NominatimResult): string {
|
||||
return result.display_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract structured address components
|
||||
*/
|
||||
parseAddress(result: NominatimResult) {
|
||||
const { address } = result;
|
||||
|
||||
return {
|
||||
street: address.road
|
||||
? `${address.road}${address.house_number ? ' ' + address.house_number : ''}`
|
||||
: '',
|
||||
city: address.city || address.town || address.village || address.municipality || '',
|
||||
postalCode: address.postcode || '',
|
||||
latitude: parseFloat(result.lat),
|
||||
longitude: parseFloat(result.lon),
|
||||
displayName: result.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocode a structured address to coordinates
|
||||
*/
|
||||
async geocodeAddress(
|
||||
street: string,
|
||||
city: string,
|
||||
postalCode?: string
|
||||
): Promise<NominatimResult | null> {
|
||||
const parts = [street, city];
|
||||
if (postalCode) parts.push(postalCode);
|
||||
parts.push('Spain');
|
||||
|
||||
const query = parts.join(', ');
|
||||
const results = await this.searchAddress(query, 1);
|
||||
|
||||
return results.length > 0 ? results[0] : null;
|
||||
}
|
||||
}
|
||||
|
||||
export const nominatimService = new NominatimService();
|
||||
export default nominatimService;
|
||||
@@ -1,25 +1,32 @@
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
SubscriptionLimits,
|
||||
FeatureCheckResponse,
|
||||
UsageCheckResponse,
|
||||
UsageSummary,
|
||||
// New types
|
||||
SubscriptionTier,
|
||||
SUBSCRIPTION_TIERS,
|
||||
BillingCycle,
|
||||
PlanMetadata,
|
||||
AvailablePlans,
|
||||
UsageSummary,
|
||||
FeatureCheckResponse,
|
||||
QuotaCheckResponse,
|
||||
PlanUpgradeValidation,
|
||||
PlanUpgradeResult,
|
||||
SUBSCRIPTION_PLANS,
|
||||
doesPlanMeetMinimum,
|
||||
getPlanColor,
|
||||
getYearlyDiscountPercentage,
|
||||
PLAN_HIERARCHY,
|
||||
|
||||
// Analytics levels
|
||||
ANALYTICS_LEVELS,
|
||||
AnalyticsLevel,
|
||||
SubscriptionPlanKey,
|
||||
PLAN_HIERARCHY,
|
||||
ANALYTICS_HIERARCHY
|
||||
} from '../types/subscription';
|
||||
|
||||
// Map plan keys to analytics levels based on backend data
|
||||
const PLAN_TO_ANALYTICS_LEVEL: Record<SubscriptionPlanKey, AnalyticsLevel> = {
|
||||
[SUBSCRIPTION_PLANS.STARTER]: ANALYTICS_LEVELS.BASIC,
|
||||
[SUBSCRIPTION_PLANS.PROFESSIONAL]: ANALYTICS_LEVELS.ADVANCED,
|
||||
[SUBSCRIPTION_PLANS.ENTERPRISE]: ANALYTICS_LEVELS.PREDICTIVE
|
||||
// Map plan tiers to analytics levels based on backend data
|
||||
const TIER_TO_ANALYTICS_LEVEL: Record<SubscriptionTier, AnalyticsLevel> = {
|
||||
[SUBSCRIPTION_TIERS.STARTER]: ANALYTICS_LEVELS.BASIC,
|
||||
[SUBSCRIPTION_TIERS.PROFESSIONAL]: ANALYTICS_LEVELS.ADVANCED,
|
||||
[SUBSCRIPTION_TIERS.ENTERPRISE]: ANALYTICS_LEVELS.PREDICTIVE
|
||||
};
|
||||
|
||||
// Cache for available plans
|
||||
@@ -29,11 +36,145 @@ const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export class SubscriptionService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
private readonly plansUrl = '/plans';
|
||||
|
||||
async getSubscriptionLimits(tenantId: string): Promise<SubscriptionLimits> {
|
||||
return apiClient.get<SubscriptionLimits>(`${this.baseUrl}/subscriptions/${tenantId}/limits`);
|
||||
// ============================================================================
|
||||
// NEW METHODS - Centralized Plans API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch available subscription plans with complete metadata
|
||||
* Uses cached data if available and fresh (5 min cache)
|
||||
*/
|
||||
async fetchAvailablePlans(): Promise<AvailablePlans> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached data if it's still valid
|
||||
if (cachedPlans && lastFetchTime && (now - lastFetchTime) < CACHE_DURATION) {
|
||||
return cachedPlans;
|
||||
}
|
||||
|
||||
try {
|
||||
const plans = await apiClient.get<AvailablePlans>(this.plansUrl);
|
||||
cachedPlans = plans;
|
||||
lastFetchTime = now;
|
||||
return plans;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch subscription plans:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a specific plan tier
|
||||
*/
|
||||
async getPlanMetadata(tier: SubscriptionTier): Promise<PlanMetadata | null> {
|
||||
try {
|
||||
const plans = await this.fetchAvailablePlans();
|
||||
return plans.plans[tier] || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get plan metadata:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available features for a tier
|
||||
*/
|
||||
async getPlanFeatures(tier: SubscriptionTier): Promise<string[]> {
|
||||
try {
|
||||
const metadata = await this.getPlanMetadata(tier);
|
||||
return metadata?.features || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get plan features:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is available in a tier
|
||||
*/
|
||||
async hasFeatureInTier(tier: SubscriptionTier, featureName: string): Promise<boolean> {
|
||||
try {
|
||||
const features = await this.getPlanFeatures(tier);
|
||||
return features.includes(featureName);
|
||||
} catch (error) {
|
||||
console.error('Failed to check feature availability:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan comparison data for pricing page
|
||||
*/
|
||||
async getPlanComparison(): Promise<{
|
||||
tiers: SubscriptionTier[];
|
||||
metadata: Record<SubscriptionTier, PlanMetadata>;
|
||||
}> {
|
||||
try {
|
||||
const plans = await this.fetchAvailablePlans();
|
||||
return {
|
||||
tiers: [
|
||||
SUBSCRIPTION_TIERS.STARTER,
|
||||
SUBSCRIPTION_TIERS.PROFESSIONAL,
|
||||
SUBSCRIPTION_TIERS.ENTERPRISE
|
||||
],
|
||||
metadata: plans.plans
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get plan comparison:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate savings for yearly billing
|
||||
*/
|
||||
calculateYearlySavings(monthlyPrice: number, yearlyPrice: number): {
|
||||
savingsAmount: number;
|
||||
savingsPercentage: number;
|
||||
monthsFree: number;
|
||||
} {
|
||||
const yearlyAnnual = monthlyPrice * 12;
|
||||
const savingsAmount = yearlyAnnual - yearlyPrice;
|
||||
const savingsPercentage = getYearlyDiscountPercentage(monthlyPrice, yearlyPrice);
|
||||
const monthsFree = Math.round(savingsAmount / monthlyPrice);
|
||||
|
||||
return {
|
||||
savingsAmount,
|
||||
savingsPercentage,
|
||||
monthsFree
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user's plan meets minimum requirement
|
||||
*/
|
||||
checkPlanMeetsMinimum(userPlan: SubscriptionTier, requiredPlan: SubscriptionTier): boolean {
|
||||
return doesPlanMeetMinimum(userPlan, requiredPlan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan display color
|
||||
*/
|
||||
getPlanDisplayColor(tier: SubscriptionTier): string {
|
||||
return getPlanColor(tier);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TENANT SUBSCRIPTION STATUS & USAGE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current usage summary for a tenant
|
||||
*/
|
||||
async getUsageSummary(tenantId: string): Promise<UsageSummary> {
|
||||
return apiClient.get<UsageSummary>(`${this.baseUrl}/subscriptions/${tenantId}/usage`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tenant has access to a specific feature
|
||||
*/
|
||||
async checkFeatureAccess(
|
||||
tenantId: string,
|
||||
featureName: string
|
||||
@@ -43,49 +184,24 @@ export class SubscriptionService {
|
||||
);
|
||||
}
|
||||
|
||||
async checkUsageLimit(
|
||||
/**
|
||||
* Check if tenant can perform an action within quota limits
|
||||
*/
|
||||
async checkQuotaLimit(
|
||||
tenantId: string,
|
||||
resourceType: 'users' | 'sales_records' | 'inventory_items' | 'api_requests',
|
||||
quotaType: string,
|
||||
requestedAmount?: number
|
||||
): Promise<UsageCheckResponse> {
|
||||
): Promise<QuotaCheckResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (requestedAmount !== undefined) {
|
||||
queryParams.append('requested_amount', requestedAmount.toString());
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/subscriptions/${tenantId}/usage/${resourceType}/check?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/subscriptions/${tenantId}/usage/${resourceType}/check`;
|
||||
? `${this.baseUrl}/subscriptions/${tenantId}/quotas/${quotaType}/check?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/subscriptions/${tenantId}/quotas/${quotaType}/check`;
|
||||
|
||||
return apiClient.get<UsageCheckResponse>(url);
|
||||
}
|
||||
|
||||
async recordUsage(
|
||||
tenantId: string,
|
||||
resourceType: 'users' | 'sales_records' | 'inventory_items' | 'api_requests',
|
||||
amount: number = 1
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.post<{ success: boolean; message: string }>(
|
||||
`${this.baseUrl}/subscriptions/${tenantId}/usage/${resourceType}/record`,
|
||||
{ amount }
|
||||
);
|
||||
}
|
||||
|
||||
async getCurrentUsage(tenantId: string): Promise<{
|
||||
users: number;
|
||||
sales_records: number;
|
||||
inventory_items: number;
|
||||
api_requests_this_hour: number;
|
||||
}> {
|
||||
return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/usage/current`);
|
||||
}
|
||||
|
||||
async getUsageSummary(tenantId: string): Promise<UsageSummary> {
|
||||
return apiClient.get<UsageSummary>(`${this.baseUrl}/subscriptions/${tenantId}/usage`);
|
||||
}
|
||||
|
||||
async getAvailablePlans(): Promise<AvailablePlans> {
|
||||
return apiClient.get<AvailablePlans>('/plans');
|
||||
return apiClient.get<QuotaCheckResponse>(url);
|
||||
}
|
||||
|
||||
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
|
||||
@@ -121,27 +237,6 @@ export class SubscriptionService {
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available subscription plans from the backend
|
||||
*/
|
||||
async fetchAvailablePlans(): Promise<AvailablePlans> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached data if it's still valid
|
||||
if (cachedPlans && lastFetchTime && (now - lastFetchTime) < CACHE_DURATION) {
|
||||
return cachedPlans;
|
||||
}
|
||||
|
||||
try {
|
||||
const plans = await apiClient.get<AvailablePlans>('/plans');
|
||||
cachedPlans = plans;
|
||||
lastFetchTime = now;
|
||||
return plans;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch subscription plans:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan display information
|
||||
@@ -149,17 +244,17 @@ export class SubscriptionService {
|
||||
async getPlanDisplayInfo(planKey: string) {
|
||||
try {
|
||||
const plans = await this.fetchAvailablePlans();
|
||||
const plan = plans.plans[planKey];
|
||||
|
||||
const plan = plans.plans[planKey as SubscriptionTier];
|
||||
|
||||
if (plan) {
|
||||
return {
|
||||
name: plan.name,
|
||||
color: this.getPlanColor(planKey),
|
||||
color: this.getPlanColor(planKey as SubscriptionTier),
|
||||
description: plan.description,
|
||||
monthlyPrice: plan.monthly_price
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return { name: 'Desconocido', color: 'gray', description: '', monthlyPrice: 0 };
|
||||
} catch (error) {
|
||||
console.error('Failed to get plan display info:', error);
|
||||
@@ -172,11 +267,11 @@ export class SubscriptionService {
|
||||
*/
|
||||
getPlanColor(planKey: string): string {
|
||||
switch (planKey) {
|
||||
case SUBSCRIPTION_PLANS.STARTER:
|
||||
case SUBSCRIPTION_TIERS.STARTER:
|
||||
return 'blue';
|
||||
case SUBSCRIPTION_PLANS.PROFESSIONAL:
|
||||
case SUBSCRIPTION_TIERS.PROFESSIONAL:
|
||||
return 'purple';
|
||||
case SUBSCRIPTION_PLANS.ENTERPRISE:
|
||||
case SUBSCRIPTION_TIERS.ENTERPRISE:
|
||||
return 'amber';
|
||||
default:
|
||||
return 'gray';
|
||||
@@ -184,17 +279,18 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plan meets minimum requirements
|
||||
* Get analytics level for a plan tier
|
||||
*/
|
||||
doesPlanMeetMinimum(plan: SubscriptionPlanKey, minimumRequired: SubscriptionPlanKey): boolean {
|
||||
return PLAN_HIERARCHY[plan] >= PLAN_HIERARCHY[minimumRequired];
|
||||
getAnalyticsLevelForTier(tier: SubscriptionTier): AnalyticsLevel {
|
||||
return TIER_TO_ANALYTICS_LEVEL[tier] || ANALYTICS_LEVELS.NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics level for a plan
|
||||
* Get analytics level for a plan (alias for getAnalyticsLevelForTier)
|
||||
* @deprecated Use getAnalyticsLevelForTier instead
|
||||
*/
|
||||
getAnalyticsLevelForPlan(plan: SubscriptionPlanKey): AnalyticsLevel {
|
||||
return PLAN_TO_ANALYTICS_LEVEL[plan] || ANALYTICS_LEVELS.NONE;
|
||||
getAnalyticsLevelForPlan(tier: SubscriptionTier): AnalyticsLevel {
|
||||
return this.getAnalyticsLevelForTier(tier);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,38 +299,6 @@ export class SubscriptionService {
|
||||
doesAnalyticsLevelMeetMinimum(level: AnalyticsLevel, minimumRequired: AnalyticsLevel): boolean {
|
||||
return ANALYTICS_HIERARCHY[level] >= ANALYTICS_HIERARCHY[minimumRequired];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan features
|
||||
*/
|
||||
async getPlanFeatures(planKey: string) {
|
||||
try {
|
||||
const plans = await this.fetchAvailablePlans();
|
||||
const plan = plans.plans[planKey];
|
||||
|
||||
if (plan) {
|
||||
return plan.features || {};
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch (error) {
|
||||
console.error('Failed to get plan features:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plan has a specific feature
|
||||
*/
|
||||
async planHasFeature(planKey: string, featureName: string) {
|
||||
try {
|
||||
const features = await this.getPlanFeatures(planKey);
|
||||
return featureName in features;
|
||||
} catch (error) {
|
||||
console.error('Failed to check plan feature:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionService = new SubscriptionService();
|
||||
@@ -1,21 +1,216 @@
|
||||
/**
|
||||
* Subscription API Types - Mirror backend schemas
|
||||
* Subscription API Types - Mirror backend centralized plans configuration
|
||||
* Source: /shared/subscription/plans.py
|
||||
*/
|
||||
|
||||
export interface SubscriptionLimits {
|
||||
max_users: number;
|
||||
max_sales_records: number;
|
||||
max_inventory_items: number;
|
||||
max_api_requests_per_hour: number;
|
||||
features_enabled: string[];
|
||||
current_usage: {
|
||||
users: number;
|
||||
sales_records: number;
|
||||
inventory_items: number;
|
||||
api_requests_this_hour: number;
|
||||
// ============================================================================
|
||||
// SUBSCRIPTION PLAN ENUMS
|
||||
// ============================================================================
|
||||
|
||||
export const SUBSCRIPTION_TIERS = {
|
||||
STARTER: 'starter',
|
||||
PROFESSIONAL: 'professional',
|
||||
ENTERPRISE: 'enterprise'
|
||||
} as const;
|
||||
|
||||
export type SubscriptionTier = typeof SUBSCRIPTION_TIERS[keyof typeof SUBSCRIPTION_TIERS];
|
||||
|
||||
export const BILLING_CYCLES = {
|
||||
MONTHLY: 'monthly',
|
||||
YEARLY: 'yearly'
|
||||
} as const;
|
||||
|
||||
export type BillingCycle = typeof BILLING_CYCLES[keyof typeof BILLING_CYCLES];
|
||||
|
||||
// ============================================================================
|
||||
// QUOTA LIMITS
|
||||
// ============================================================================
|
||||
|
||||
export interface QuotaLimits {
|
||||
// Team & Organization
|
||||
max_users?: number | null; // null = unlimited
|
||||
max_locations?: number | null;
|
||||
|
||||
// Product & Inventory
|
||||
max_products?: number | null;
|
||||
max_recipes?: number | null;
|
||||
max_suppliers?: number | null;
|
||||
|
||||
// ML & Analytics (Daily)
|
||||
training_jobs_per_day?: number | null;
|
||||
forecast_generation_per_day?: number | null;
|
||||
|
||||
// Data Limits
|
||||
dataset_size_rows?: number | null;
|
||||
forecast_horizon_days?: number | null;
|
||||
historical_data_access_days?: number | null;
|
||||
|
||||
// Import/Export
|
||||
bulk_import_rows?: number | null;
|
||||
bulk_export_rows?: number | null;
|
||||
|
||||
// Integrations
|
||||
pos_sync_interval_minutes?: number | null;
|
||||
api_calls_per_hour?: number | null;
|
||||
webhook_endpoints?: number | null;
|
||||
|
||||
// Storage
|
||||
file_storage_gb?: number | null;
|
||||
report_retention_days?: number | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAN FEATURES
|
||||
// ============================================================================
|
||||
|
||||
export interface PlanFeatures {
|
||||
// Core features (all tiers)
|
||||
inventory_management: boolean;
|
||||
sales_tracking: boolean;
|
||||
basic_recipes: boolean;
|
||||
production_planning: boolean;
|
||||
basic_reporting: boolean;
|
||||
mobile_app_access: boolean;
|
||||
email_support: boolean;
|
||||
easy_step_by_step_onboarding: boolean;
|
||||
|
||||
// Starter+ features
|
||||
basic_forecasting?: boolean;
|
||||
demand_prediction?: boolean;
|
||||
waste_tracking?: boolean;
|
||||
order_management?: boolean;
|
||||
customer_management?: boolean;
|
||||
supplier_management?: boolean;
|
||||
batch_tracking?: boolean;
|
||||
expiry_alerts?: boolean;
|
||||
|
||||
// Professional+ features
|
||||
advanced_analytics?: boolean;
|
||||
custom_reports?: boolean;
|
||||
sales_analytics?: boolean;
|
||||
supplier_performance?: boolean;
|
||||
waste_analysis?: boolean;
|
||||
profitability_analysis?: boolean;
|
||||
weather_data_integration?: boolean;
|
||||
traffic_data_integration?: boolean;
|
||||
multi_location_support?: boolean;
|
||||
location_comparison?: boolean;
|
||||
inventory_transfer?: boolean;
|
||||
batch_scaling?: boolean;
|
||||
recipe_feasibility_check?: boolean;
|
||||
seasonal_patterns?: boolean;
|
||||
longer_forecast_horizon?: boolean;
|
||||
pos_integration?: boolean;
|
||||
accounting_export?: boolean;
|
||||
basic_api_access?: boolean;
|
||||
priority_email_support?: boolean;
|
||||
phone_support?: boolean;
|
||||
|
||||
// Enterprise features
|
||||
scenario_modeling?: boolean;
|
||||
what_if_analysis?: boolean;
|
||||
risk_assessment?: boolean;
|
||||
advanced_ml_parameters?: boolean;
|
||||
model_artifacts_access?: boolean;
|
||||
custom_algorithms?: boolean;
|
||||
full_api_access?: boolean;
|
||||
unlimited_webhooks?: boolean;
|
||||
erp_integration?: boolean;
|
||||
custom_integrations?: boolean;
|
||||
multi_tenant_management?: boolean;
|
||||
white_label_option?: boolean;
|
||||
custom_branding?: boolean;
|
||||
sso_saml?: boolean;
|
||||
advanced_permissions?: boolean;
|
||||
audit_logs_export?: boolean;
|
||||
compliance_reports?: boolean;
|
||||
benchmarking?: boolean;
|
||||
competitive_analysis?: boolean;
|
||||
market_insights?: boolean;
|
||||
predictive_maintenance?: boolean;
|
||||
dedicated_account_manager?: boolean;
|
||||
priority_support?: boolean;
|
||||
support_24_7?: boolean;
|
||||
custom_training?: boolean;
|
||||
onsite_support?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAN METADATA
|
||||
// ============================================================================
|
||||
|
||||
export interface PlanMetadata {
|
||||
name: string;
|
||||
description: string;
|
||||
tagline: string;
|
||||
popular: boolean;
|
||||
monthly_price: number;
|
||||
yearly_price: number;
|
||||
trial_days: number;
|
||||
features: string[]; // List of feature keys
|
||||
limits: {
|
||||
users: number | null;
|
||||
locations: number | null;
|
||||
products: number | null;
|
||||
forecasts_per_day: number | null;
|
||||
};
|
||||
support: string;
|
||||
recommended_for: string;
|
||||
contact_sales?: boolean;
|
||||
}
|
||||
|
||||
export interface AvailablePlans {
|
||||
plans: {
|
||||
[key in SubscriptionTier]: PlanMetadata;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USAGE & SUBSCRIPTION STATUS
|
||||
// ============================================================================
|
||||
|
||||
export interface UsageMetric {
|
||||
current: number;
|
||||
limit: number | null;
|
||||
unlimited: boolean;
|
||||
usage_percentage: number;
|
||||
}
|
||||
|
||||
export interface CurrentUsage {
|
||||
// Team & Organization
|
||||
users: UsageMetric;
|
||||
locations: UsageMetric;
|
||||
|
||||
// Product & Inventory
|
||||
products: UsageMetric;
|
||||
recipes: UsageMetric;
|
||||
suppliers: UsageMetric;
|
||||
|
||||
// ML & Analytics (Daily)
|
||||
training_jobs_today: UsageMetric;
|
||||
forecasts_today: UsageMetric;
|
||||
|
||||
// API Usage (Hourly)
|
||||
api_calls_this_hour: UsageMetric;
|
||||
|
||||
// Storage
|
||||
file_storage_used_gb: UsageMetric;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
plan: SubscriptionTier;
|
||||
status: 'active' | 'inactive' | 'trialing' | 'past_due' | 'cancelled';
|
||||
billing_cycle: BillingCycle;
|
||||
monthly_price: number;
|
||||
next_billing_date: string;
|
||||
trial_ends_at?: string;
|
||||
usage: CurrentUsage;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FEATURE & QUOTA CHECKS
|
||||
// ============================================================================
|
||||
|
||||
export interface FeatureCheckRequest {
|
||||
feature_name: string;
|
||||
tenant_id: string;
|
||||
@@ -23,80 +218,112 @@ export interface FeatureCheckRequest {
|
||||
|
||||
export interface FeatureCheckResponse {
|
||||
enabled: boolean;
|
||||
limit?: number;
|
||||
current_usage?: number;
|
||||
requires_upgrade: boolean;
|
||||
required_tier?: SubscriptionTier;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface UsageCheckRequest {
|
||||
resource_type: 'users' | 'sales_records' | 'inventory_items' | 'api_requests';
|
||||
export interface QuotaCheckRequest {
|
||||
quota_type: string;
|
||||
tenant_id: string;
|
||||
requested_amount?: number;
|
||||
}
|
||||
|
||||
export interface UsageCheckResponse {
|
||||
export interface QuotaCheckResponse {
|
||||
allowed: boolean;
|
||||
limit: number;
|
||||
current_usage: number;
|
||||
remaining: number;
|
||||
current: number;
|
||||
limit: number | null;
|
||||
remaining: number | null;
|
||||
reset_at?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
plan: string;
|
||||
status: 'active' | 'inactive' | 'past_due' | 'cancelled';
|
||||
monthly_price: number;
|
||||
next_billing_date: string;
|
||||
usage: {
|
||||
users: {
|
||||
current: number;
|
||||
limit: number;
|
||||
unlimited: boolean;
|
||||
usage_percentage: number;
|
||||
};
|
||||
locations: {
|
||||
current: number;
|
||||
limit: number;
|
||||
unlimited: boolean;
|
||||
usage_percentage: number;
|
||||
};
|
||||
products: {
|
||||
current: number;
|
||||
limit: number;
|
||||
unlimited: boolean;
|
||||
usage_percentage: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
name: string;
|
||||
description: string;
|
||||
monthly_price: number;
|
||||
max_users: number;
|
||||
max_locations: number;
|
||||
max_products: number;
|
||||
popular?: boolean;
|
||||
contact_sales?: boolean;
|
||||
}
|
||||
|
||||
export interface AvailablePlans {
|
||||
plans: {
|
||||
[key: string]: Plan;
|
||||
};
|
||||
}
|
||||
// ============================================================================
|
||||
// PLAN MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export interface PlanUpgradeValidation {
|
||||
can_upgrade: boolean;
|
||||
from_tier: SubscriptionTier;
|
||||
to_tier: SubscriptionTier;
|
||||
price_difference: number;
|
||||
prorated_amount?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface PlanUpgradeRequest {
|
||||
tenant_id: string;
|
||||
new_tier: SubscriptionTier;
|
||||
billing_cycle: BillingCycle;
|
||||
}
|
||||
|
||||
export interface PlanUpgradeResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
new_plan: SubscriptionTier;
|
||||
effective_date: string;
|
||||
}
|
||||
|
||||
// Analytics access levels
|
||||
export interface SubscriptionInvoice {
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
status: 'paid' | 'pending' | 'failed';
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
// Plan hierarchy for comparison
|
||||
export const PLAN_HIERARCHY: Record<SubscriptionTier, number> = {
|
||||
[SUBSCRIPTION_TIERS.STARTER]: 1,
|
||||
[SUBSCRIPTION_TIERS.PROFESSIONAL]: 2,
|
||||
[SUBSCRIPTION_TIERS.ENTERPRISE]: 3
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a plan meets minimum tier requirement
|
||||
*/
|
||||
export function doesPlanMeetMinimum(
|
||||
userPlan: SubscriptionTier,
|
||||
requiredPlan: SubscriptionTier
|
||||
): boolean {
|
||||
return PLAN_HIERARCHY[userPlan] >= PLAN_HIERARCHY[requiredPlan];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan display color
|
||||
*/
|
||||
export function getPlanColor(tier: SubscriptionTier): string {
|
||||
switch (tier) {
|
||||
case SUBSCRIPTION_TIERS.STARTER:
|
||||
return 'blue';
|
||||
case SUBSCRIPTION_TIERS.PROFESSIONAL:
|
||||
return 'purple';
|
||||
case SUBSCRIPTION_TIERS.ENTERPRISE:
|
||||
return 'amber';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate discount percentage for yearly billing
|
||||
*/
|
||||
export function getYearlyDiscountPercentage(monthlyPrice: number, yearlyPrice: number): number {
|
||||
const yearlyAnnual = monthlyPrice * 12;
|
||||
const discount = ((yearlyAnnual - yearlyPrice) / yearlyAnnual) * 100;
|
||||
return Math.round(discount);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ANALYTICS LEVELS (for route-based analytics restrictions)
|
||||
// ============================================================================
|
||||
|
||||
export const ANALYTICS_LEVELS = {
|
||||
NONE: 'none',
|
||||
BASIC: 'basic',
|
||||
@@ -106,26 +333,9 @@ export const ANALYTICS_LEVELS = {
|
||||
|
||||
export type AnalyticsLevel = typeof ANALYTICS_LEVELS[keyof typeof ANALYTICS_LEVELS];
|
||||
|
||||
// Plan keys
|
||||
export const SUBSCRIPTION_PLANS = {
|
||||
STARTER: 'starter',
|
||||
PROFESSIONAL: 'professional',
|
||||
ENTERPRISE: 'enterprise'
|
||||
} as const;
|
||||
|
||||
export type SubscriptionPlanKey = typeof SUBSCRIPTION_PLANS[keyof typeof SUBSCRIPTION_PLANS];
|
||||
|
||||
// Plan hierarchy for comparison
|
||||
export const PLAN_HIERARCHY: Record<SubscriptionPlanKey, number> = {
|
||||
[SUBSCRIPTION_PLANS.STARTER]: 1,
|
||||
[SUBSCRIPTION_PLANS.PROFESSIONAL]: 2,
|
||||
[SUBSCRIPTION_PLANS.ENTERPRISE]: 3
|
||||
};
|
||||
|
||||
// Analytics level hierarchy
|
||||
export const ANALYTICS_HIERARCHY: Record<AnalyticsLevel, number> = {
|
||||
[ANALYTICS_LEVELS.NONE]: 0,
|
||||
[ANALYTICS_LEVELS.BASIC]: 1,
|
||||
[ANALYTICS_LEVELS.ADVANCED]: 2,
|
||||
[ANALYTICS_LEVELS.PREDICTIVE]: 3
|
||||
};
|
||||
};
|
||||
|
||||
@@ -168,6 +168,8 @@ export interface TrainingJobStatus {
|
||||
products_completed: number;
|
||||
products_failed: number;
|
||||
error_message?: string | null;
|
||||
estimated_time_remaining_seconds?: number | null; // Estimated time remaining in seconds
|
||||
message?: string | null; // Optional status message
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@ import React from 'react';
|
||||
import { Modal, Button, Card } from '../ui';
|
||||
import { Crown, Lock, ArrowRight, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
SUBSCRIPTION_PLANS,
|
||||
SUBSCRIPTION_TIERS,
|
||||
ANALYTICS_LEVELS
|
||||
} from '../../api/types/subscription';
|
||||
import { subscriptionService } from '../../api/services/subscription';
|
||||
@@ -59,19 +59,19 @@ const SubscriptionErrorHandler: React.FC<SubscriptionErrorHandlerProps> = ({
|
||||
const getRequiredPlan = (level: string) => {
|
||||
switch (level) {
|
||||
case ANALYTICS_LEVELS.ADVANCED:
|
||||
return SUBSCRIPTION_PLANS.PROFESSIONAL;
|
||||
return SUBSCRIPTION_TIERS.PROFESSIONAL;
|
||||
case ANALYTICS_LEVELS.PREDICTIVE:
|
||||
return SUBSCRIPTION_PLANS.ENTERPRISE;
|
||||
return SUBSCRIPTION_TIERS.ENTERPRISE;
|
||||
default:
|
||||
return SUBSCRIPTION_PLANS.PROFESSIONAL;
|
||||
return SUBSCRIPTION_TIERS.PROFESSIONAL;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanColor = (plan: string) => {
|
||||
switch (plan.toLowerCase()) {
|
||||
case SUBSCRIPTION_PLANS.PROFESSIONAL:
|
||||
case SUBSCRIPTION_TIERS.PROFESSIONAL:
|
||||
return 'bg-gradient-to-br from-purple-500 to-indigo-600';
|
||||
case SUBSCRIPTION_PLANS.ENTERPRISE:
|
||||
case SUBSCRIPTION_TIERS.ENTERPRISE:
|
||||
return 'bg-gradient-to-br from-yellow-400 to-orange-500';
|
||||
default:
|
||||
return 'bg-gradient-to-br from-blue-500 to-cyan-600';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Button, Badge } from '../../ui';
|
||||
import { CheckCircle, Users, MapPin, Package, TrendingUp, Star, ArrowRight } from 'lucide-react';
|
||||
import { subscriptionService, type AvailablePlans } from '../../../api';
|
||||
import { CheckCircle, Users, MapPin, Package, TrendingUp, Star, ArrowRight, Zap } from 'lucide-react';
|
||||
import { subscriptionService, type AvailablePlans, type PlanMetadata, SUBSCRIPTION_TIERS } from '../../../api';
|
||||
|
||||
interface SubscriptionSelectionProps {
|
||||
selectedPlan: string;
|
||||
@@ -24,14 +24,18 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const plans = await subscriptionService.getAvailablePlans();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const plans = await subscriptionService.fetchAvailablePlans();
|
||||
setAvailablePlans(plans);
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscription plans:', error);
|
||||
} catch (err) {
|
||||
console.error('Error fetching subscription plans:', err);
|
||||
setError('No se pudieron cargar los planes. Por favor, intenta de nuevo.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -40,7 +44,7 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
|
||||
fetchPlans();
|
||||
}, []);
|
||||
|
||||
if (loading || !availablePlans) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-color-primary"></div>
|
||||
@@ -48,19 +52,107 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !availablePlans) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-4">
|
||||
<div className="text-color-error text-center">
|
||||
<p className="font-semibold">{error || 'Error al cargar los planes'}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Intentar de nuevo
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleTrialToggle = () => {
|
||||
if (onTrialSelect) {
|
||||
onTrialSelect(!trialSelected);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to translate feature names to Spanish
|
||||
const translateFeature = (feature: string): string => {
|
||||
const translations: Record<string, string> = {
|
||||
'inventory_management': 'Gestión de inventario',
|
||||
'sales_tracking': 'Seguimiento de ventas',
|
||||
'basic_analytics': 'Analíticas básicas',
|
||||
'basic_forecasting': 'Pronósticos básicos',
|
||||
'pos_integration': 'Punto de venta integrado',
|
||||
'production_planning': 'Planificación de producción',
|
||||
'supplier_management': 'Gestión de proveedores',
|
||||
'recipe_management': 'Gestión de recetas',
|
||||
'advanced_analytics': 'Analíticas avanzadas',
|
||||
'ai_forecasting': 'Pronósticos con IA',
|
||||
'weather_data_integration': 'Integración datos meteorológicos',
|
||||
'multi_location': 'Multi-ubicación',
|
||||
'custom_reports': 'Reportes personalizados',
|
||||
'api_access': 'Acceso API',
|
||||
'priority_support': 'Soporte prioritario',
|
||||
'dedicated_account_manager': 'Manager de cuenta dedicado',
|
||||
'sla_guarantee': 'Garantía SLA',
|
||||
'custom_integrations': 'Integraciones personalizadas',
|
||||
'white_label': 'Marca blanca',
|
||||
'advanced_security': 'Seguridad avanzada',
|
||||
'audit_logs': 'Registros de auditoría',
|
||||
'role_based_access': 'Control de acceso basado en roles',
|
||||
'custom_workflows': 'Flujos de trabajo personalizados',
|
||||
'training_sessions': 'Sesiones de capacitación',
|
||||
'onboarding_support': 'Soporte de incorporación',
|
||||
'data_export': 'Exportación de datos',
|
||||
'backup_restore': 'Respaldo y restauración',
|
||||
'mobile_app': 'Aplicación móvil',
|
||||
'offline_mode': 'Modo offline',
|
||||
'real_time_sync': 'Sincronización en tiempo real',
|
||||
'notifications': 'Notificaciones',
|
||||
'email_alerts': 'Alertas por email',
|
||||
'sms_alerts': 'Alertas por SMS',
|
||||
'inventory_alerts': 'Alertas de inventario',
|
||||
'low_stock_alerts': 'Alertas de stock bajo',
|
||||
'expiration_tracking': 'Seguimiento de caducidad',
|
||||
'batch_tracking': 'Seguimiento de lotes',
|
||||
'quality_control': 'Control de calidad',
|
||||
'compliance_reporting': 'Reportes de cumplimiento',
|
||||
'financial_reports': 'Reportes financieros',
|
||||
'tax_reports': 'Reportes de impuestos',
|
||||
'waste_tracking': 'Seguimiento de desperdicios',
|
||||
'cost_analysis': 'Análisis de costos',
|
||||
'profit_margins': 'Márgenes de ganancia',
|
||||
'sales_forecasting': 'Pronóstico de ventas',
|
||||
'demand_planning': 'Planificación de demanda',
|
||||
'seasonal_trends': 'Tendencias estacionales',
|
||||
'customer_analytics': 'Analíticas de clientes',
|
||||
'loyalty_program': 'Programa de lealtad',
|
||||
'discount_management': 'Gestión de descuentos',
|
||||
'promotion_tracking': 'Seguimiento de promociones',
|
||||
'gift_cards': 'Tarjetas de regalo',
|
||||
'online_ordering': 'Pedidos en línea',
|
||||
'delivery_management': 'Gestión de entregas',
|
||||
'route_optimization': 'Optimización de rutas',
|
||||
'driver_tracking': 'Seguimiento de conductores',
|
||||
'customer_portal': 'Portal de clientes',
|
||||
'vendor_portal': 'Portal de proveedores',
|
||||
'invoice_management': 'Gestión de facturas',
|
||||
'payment_processing': 'Procesamiento de pagos',
|
||||
'purchase_orders': 'Órdenes de compra',
|
||||
'receiving_management': 'Gestión de recepciones'
|
||||
};
|
||||
return translations[feature] || feature.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
// Get trial days from the selected plan (default to 14 if not available)
|
||||
const trialDays = availablePlans.plans[selectedPlan]?.trial_days || 14;
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{showTrialOption && (
|
||||
<Card className="p-4 border-2 border-color-primary/30 bg-bg-primary">
|
||||
<Card className="p-4 border-2 border-color-primary/30 bg-gradient-to-r from-color-primary/5 to-color-primary/10">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="p-2.5 bg-color-primary/10 rounded-lg flex-shrink-0">
|
||||
<div className="p-2.5 bg-color-primary/20 rounded-lg flex-shrink-0">
|
||||
<Star className="w-5 h-5 text-color-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -68,7 +160,7 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
|
||||
{t('auth:subscription.trial_title', 'Prueba gratuita')}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t('auth:subscription.trial_description', 'Obtén 3 meses de prueba gratuita como usuario piloto')}
|
||||
{t('auth:subscription.trial_description', `Obtén ${trialDays} días de prueba gratuita - sin tarjeta de crédito requerida`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,9 +170,14 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
|
||||
onClick={handleTrialToggle}
|
||||
className="w-full sm:w-auto flex-shrink-0 min-w-[100px]"
|
||||
>
|
||||
{trialSelected
|
||||
? t('auth:subscription.trial_active', 'Activo')
|
||||
: t('auth:subscription.trial_activate', 'Activar')}
|
||||
{trialSelected ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>{t('auth:subscription.trial_active', 'Activo')}</span>
|
||||
</div>
|
||||
) : (
|
||||
t('auth:subscription.trial_activate', 'Activar')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -89,19 +186,20 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
|
||||
<div className="space-y-3">
|
||||
{Object.entries(availablePlans.plans).map(([planKey, plan]) => {
|
||||
const isSelected = selectedPlan === planKey;
|
||||
const metadata = plan as PlanMetadata;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={planKey}
|
||||
className={`relative p-6 cursor-pointer transition-all duration-200 border-2 ${
|
||||
isSelected
|
||||
? 'border-color-primary bg-color-primary/5 shadow-lg'
|
||||
? 'border-color-primary bg-color-primary/5 shadow-lg ring-2 ring-color-primary/20'
|
||||
: 'border-border-primary bg-bg-primary hover:border-color-primary/40 hover:shadow-md'
|
||||
} ${plan.popular ? 'pt-8' : ''}`}
|
||||
} ${metadata.popular ? 'pt-8' : ''}`}
|
||||
onClick={() => onPlanSelect(planKey)}
|
||||
>
|
||||
{/* Popular Badge */}
|
||||
{plan.popular && (
|
||||
{metadata.popular && (
|
||||
<div className="absolute top-0 left-0 right-0 flex justify-center -translate-y-1/2 z-20">
|
||||
<Badge variant="primary" className="px-4 py-1.5 text-xs font-bold flex items-center gap-1.5 shadow-lg rounded-full">
|
||||
<Star className="w-3.5 h-3.5 fill-current" />
|
||||
@@ -115,14 +213,28 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
|
||||
{/* Header Section: Plan Info & Pricing */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-2xl font-bold text-text-primary mb-2">{plan.name}</h4>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-2xl font-bold text-text-primary">{metadata.name}</h4>
|
||||
{metadata.trial_days > 0 && (
|
||||
<Badge variant="success" className="text-xs px-2 py-0.5">
|
||||
<Zap className="w-3 h-3 mr-1" />
|
||||
{metadata.trial_days} días gratis
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-color-primary font-semibold mb-3">{metadata.tagline}</p>
|
||||
<div className="flex items-baseline gap-1 mb-3">
|
||||
<span className="text-4xl font-bold text-color-primary">
|
||||
{subscriptionService.formatPrice(plan.monthly_price)}
|
||||
{subscriptionService.formatPrice(metadata.monthly_price)}
|
||||
</span>
|
||||
<span className="text-base text-text-secondary font-medium">/mes</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary leading-relaxed max-w-prose">{plan.description}</p>
|
||||
<p className="text-sm text-text-secondary leading-relaxed max-w-prose">{metadata.description}</p>
|
||||
{metadata.recommended_for && (
|
||||
<p className="text-xs text-text-tertiary mt-2 italic">
|
||||
💡 {metadata.recommended_for}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Button - Desktop position */}
|
||||
@@ -155,71 +267,73 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 pt-4 border-t border-border-primary/50">
|
||||
{/* Plan Limits */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2.5 text-sm text-text-primary">
|
||||
<Users className="w-4 h-4 text-color-primary flex-shrink-0" />
|
||||
<span className="font-medium">{plan.max_users === -1 ? 'Usuarios ilimitados' : `${plan.max_users} usuario${plan.max_users > 1 ? 's' : ''}`}</span>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Package className="w-5 h-5 text-color-primary flex-shrink-0" />
|
||||
<h5 className="text-base font-bold text-text-primary">
|
||||
Límites del Plan
|
||||
</h5>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-sm text-text-primary">
|
||||
<MapPin className="w-4 h-4 text-color-primary flex-shrink-0" />
|
||||
<span className="font-medium">{plan.max_locations === -1 ? 'Ubicaciones ilimitadas' : `${plan.max_locations} ubicación${plan.max_locations > 1 ? 'es' : ''}`}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-sm text-text-primary">
|
||||
<Package className="w-4 h-4 text-color-primary flex-shrink-0" />
|
||||
<span className="font-medium">{plan.max_products === -1 ? 'Productos ilimitados' : `${plan.max_products} producto${plan.max_products > 1 ? 's' : ''}`}</span>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center gap-2.5 text-sm text-text-primary">
|
||||
<Users className="w-4 h-4 text-color-accent flex-shrink-0" />
|
||||
<span className="font-medium">
|
||||
{metadata.limits.users === null ? 'Usuarios ilimitados' : `${metadata.limits.users} usuario${metadata.limits.users > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-sm text-text-primary">
|
||||
<MapPin className="w-4 h-4 text-color-accent flex-shrink-0" />
|
||||
<span className="font-medium">
|
||||
{metadata.limits.locations === null ? 'Ubicaciones ilimitadas' : `${metadata.limits.locations} ubicación${metadata.limits.locations > 1 ? 'es' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-sm text-text-primary">
|
||||
<Package className="w-4 h-4 text-color-accent flex-shrink-0" />
|
||||
<span className="font-medium">
|
||||
{metadata.limits.products === null ? 'Productos ilimitados' : `${metadata.limits.products} producto${metadata.limits.products > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
{metadata.limits.forecasts_per_day !== null && (
|
||||
<div className="flex items-center gap-2.5 text-sm text-text-primary">
|
||||
<TrendingUp className="w-4 h-4 text-color-accent flex-shrink-0" />
|
||||
<span className="font-medium">
|
||||
{metadata.limits.forecasts_per_day} pronóstico{metadata.limits.forecasts_per_day > 1 ? 's' : ''}/día
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="space-y-3 lg:pl-6 lg:border-l border-border-primary/50">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp className="w-5 h-5 text-color-primary flex-shrink-0" />
|
||||
<CheckCircle className="w-5 h-5 text-color-success flex-shrink-0" />
|
||||
<h5 className="text-base font-bold text-text-primary">
|
||||
{t('auth:subscription.features', 'Funcionalidades Incluidas')}
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{(() => {
|
||||
const getPlanFeatures = (planKey: string) => {
|
||||
switch (planKey) {
|
||||
case 'starter':
|
||||
return [
|
||||
'Panel de Control Básico',
|
||||
'Gestión de Inventario',
|
||||
'Gestión de Pedidos',
|
||||
'Gestión de Proveedores',
|
||||
'Punto de Venta Básico'
|
||||
];
|
||||
case 'professional':
|
||||
return [
|
||||
'Todo lo de Starter',
|
||||
'Panel Avanzado',
|
||||
'Analytics de Ventas',
|
||||
'Pronósticos con IA',
|
||||
'Optimización de Producción'
|
||||
];
|
||||
case 'enterprise':
|
||||
return [
|
||||
'Todo lo de Professional',
|
||||
'Insights Predictivos IA',
|
||||
'Analytics Multi-ubicación',
|
||||
'Integración ERP',
|
||||
'Soporte 24/7 Prioritario',
|
||||
'API Personalizada'
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return getPlanFeatures(planKey).map((feature, index) => (
|
||||
<div key={index} className="flex items-start gap-2.5 text-sm">
|
||||
<CheckCircle className="w-4 h-4 text-color-success flex-shrink-0 mt-0.5" />
|
||||
<span className="text-text-primary leading-snug">{feature}</span>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
<div className="space-y-2.5 max-h-48 overflow-y-auto pr-2 scrollbar-thin">
|
||||
{metadata.features.slice(0, 8).map((feature, index) => (
|
||||
<div key={index} className="flex items-start gap-2.5 text-sm">
|
||||
<CheckCircle className="w-4 h-4 text-color-success flex-shrink-0 mt-0.5" />
|
||||
<span className="text-text-primary leading-snug">{translateFeature(feature)}</span>
|
||||
</div>
|
||||
))}
|
||||
{metadata.features.length > 8 && (
|
||||
<p className="text-xs text-text-tertiary italic pl-6">
|
||||
+{metadata.features.length - 8} funcionalidades más
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Support Level */}
|
||||
{metadata.support && (
|
||||
<div className="pt-3 mt-3 border-t border-border-primary/30">
|
||||
<p className="text-xs text-text-secondary">
|
||||
<span className="font-semibold">Soporte:</span> {metadata.support}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../../ui/Button';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useCreateTrainingJob, useTrainingWebSocket, useTrainingJobStatus } from '../../../../api/hooks/training';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
interface MLTrainingStepProps {
|
||||
onNext: () => void;
|
||||
@@ -22,14 +25,33 @@ interface TrainingProgress {
|
||||
export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
|
||||
onComplete
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [trainingProgress, setTrainingProgress] = useState<TrainingProgress | null>(null);
|
||||
const [isTraining, setIsTraining] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [trainingStartTime, setTrainingStartTime] = useState<number | null>(null);
|
||||
const [showSkipOption, setShowSkipOption] = useState(false);
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
const createTrainingJob = useCreateTrainingJob();
|
||||
|
||||
// Check if training has been running for more than 2 minutes
|
||||
useEffect(() => {
|
||||
if (trainingStartTime && isTraining && !showSkipOption) {
|
||||
const checkTimer = setInterval(() => {
|
||||
const elapsedTime = (Date.now() - trainingStartTime) / 1000; // in seconds
|
||||
if (elapsedTime > 120) { // 2 minutes
|
||||
setShowSkipOption(true);
|
||||
clearInterval(checkTimer);
|
||||
}
|
||||
}, 5000); // Check every 5 seconds
|
||||
|
||||
return () => clearInterval(checkTimer);
|
||||
}
|
||||
}, [trainingStartTime, isTraining, showSkipOption]);
|
||||
|
||||
// Memoized WebSocket callbacks to prevent reconnections
|
||||
const handleProgress = useCallback((data: any) => {
|
||||
setTrainingProgress({
|
||||
@@ -37,7 +59,7 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
|
||||
progress: data.data?.progress || 0,
|
||||
message: data.data?.message || 'Entrenando modelo...',
|
||||
currentStep: data.data?.current_step,
|
||||
estimatedTimeRemaining: data.data?.estimated_time_remaining
|
||||
estimatedTimeRemaining: data.data?.estimated_time_remaining_seconds || data.data?.estimated_time_remaining
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -177,7 +199,8 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
|
||||
});
|
||||
|
||||
setJobId(response.job_id);
|
||||
|
||||
setTrainingStartTime(Date.now()); // Track when training started
|
||||
|
||||
setTrainingProgress({
|
||||
stage: 'queued',
|
||||
progress: 10,
|
||||
@@ -190,6 +213,12 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipToDashboard = () => {
|
||||
// Navigate to dashboard while training continues in background
|
||||
console.log('🚀 User chose to skip to dashboard while training continues');
|
||||
navigate('/app/dashboard');
|
||||
};
|
||||
|
||||
const formatTime = (seconds?: number) => {
|
||||
if (!seconds) return '';
|
||||
|
||||
@@ -273,7 +302,7 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-[var(--text-tertiary)]">
|
||||
<span>{trainingProgress.currentStep || 'Procesando...'}</span>
|
||||
<span>{trainingProgress.currentStep || t('onboarding:steps.ml_training.progress.data_preparation', 'Procesando...')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{jobId && (
|
||||
<span className={`text-xs ${isConnected ? 'text-green-500' : 'text-red-500'}`}>
|
||||
@@ -281,7 +310,7 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
|
||||
</span>
|
||||
)}
|
||||
{trainingProgress.estimatedTimeRemaining && (
|
||||
<span>Tiempo estimado: {formatTime(trainingProgress.estimatedTimeRemaining)}</span>
|
||||
<span>{t('onboarding:steps.ml_training.estimated_time_remaining', 'Tiempo restante estimado: {{time}}', { time: formatTime(trainingProgress.estimatedTimeRemaining) })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,6 +322,35 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skip to Dashboard Option - Show after 2 minutes */}
|
||||
{showSkipOption && isTraining && trainingProgress?.stage !== 'completed' && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<Info className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-1">
|
||||
{t('onboarding:steps.ml_training.skip_to_dashboard.title', '¿Toma demasiado tiempo?')}
|
||||
</h4>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">
|
||||
{t('onboarding:steps.ml_training.skip_to_dashboard.info', 'El entrenamiento está tardando más de lo esperado. No te preocupes, puedes explorar tu dashboard mientras el modelo termina de entrenarse en segundo plano.')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleSkipToDashboard}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{t('onboarding:steps.ml_training.skip_to_dashboard.button', 'Ir al Dashboard')}
|
||||
</Button>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 mt-2">
|
||||
{t('onboarding:steps.ml_training.skip_to_dashboard.training_continues', 'El entrenamiento continúa en segundo plano')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Training Info */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
|
||||
<h4 className="font-medium mb-2">¿Qué sucede durante el entrenamiento?</h4>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '../../../ui/Button';
|
||||
import { Input } from '../../../ui/Input';
|
||||
import { useRegisterBakery } from '../../../../api/hooks/tenant';
|
||||
import { BakeryRegistration } from '../../../../api/types/tenant';
|
||||
import { nominatimService, NominatimResult } from '../../../../api/services/nominatim';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
interface RegisterTenantStepProps {
|
||||
onNext: () => void;
|
||||
@@ -27,14 +29,51 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [addressSuggestions, setAddressSuggestions] = useState<NominatimResult[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const registerBakery = useRegisterBakery();
|
||||
|
||||
// Debounced address search
|
||||
const searchAddress = useCallback(
|
||||
debounce(async (query: string) => {
|
||||
if (query.length < 3) {
|
||||
setAddressSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const results = await nominatimService.searchAddress(query);
|
||||
setAddressSuggestions(results);
|
||||
setShowSuggestions(true);
|
||||
} catch (error) {
|
||||
console.error('Address search failed:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
searchAddress.cancel();
|
||||
};
|
||||
}, [searchAddress]);
|
||||
|
||||
const handleInputChange = (field: keyof BakeryRegistration, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
|
||||
|
||||
// Trigger address search when address field changes
|
||||
if (field === 'address') {
|
||||
searchAddress(value);
|
||||
}
|
||||
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
@@ -43,6 +82,20 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddressSelect = (result: NominatimResult) => {
|
||||
const parsed = nominatimService.parseAddress(result);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
address: parsed.street,
|
||||
city: parsed.city,
|
||||
postal_code: parsed.postalCode,
|
||||
}));
|
||||
|
||||
setShowSuggestions(false);
|
||||
setAddressSuggestions([]);
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
@@ -121,15 +174,43 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
isRequired
|
||||
/>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<div className="md:col-span-2 relative">
|
||||
<Input
|
||||
label="Dirección"
|
||||
placeholder="Calle Principal 123, Ciudad, Provincia"
|
||||
placeholder="Calle Principal 123, Madrid"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleInputChange('address', e.target.value)}
|
||||
onFocus={() => {
|
||||
if (addressSuggestions.length > 0) {
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => setShowSuggestions(false), 200);
|
||||
}}
|
||||
error={errors.address}
|
||||
isRequired
|
||||
/>
|
||||
{isSearching && (
|
||||
<div className="absolute right-3 top-10 text-gray-400">
|
||||
Buscando...
|
||||
</div>
|
||||
)}
|
||||
{showSuggestions && addressSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
{addressSuggestions.map((result) => (
|
||||
<div
|
||||
key={result.place_id}
|
||||
className="px-4 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0"
|
||||
onClick={() => handleAddressSelect(result)}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{nominatimService.formatAddress(result)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
|
||||
369
frontend/src/components/subscription/PricingSection.tsx
Normal file
369
frontend/src/components/subscription/PricingSection.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Check, Star, ArrowRight, Package, TrendingUp, Settings, Loader } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import {
|
||||
subscriptionService,
|
||||
type PlanMetadata,
|
||||
type SubscriptionTier,
|
||||
SUBSCRIPTION_TIERS
|
||||
} from '../../api';
|
||||
|
||||
type BillingCycle = 'monthly' | 'yearly';
|
||||
|
||||
export const PricingSection: React.FC = () => {
|
||||
const [plans, setPlans] = useState<Record<SubscriptionTier, PlanMetadata> | null>(null);
|
||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlans();
|
||||
}, []);
|
||||
|
||||
const loadPlans = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const availablePlans = await subscriptionService.fetchAvailablePlans();
|
||||
setPlans(availablePlans.plans);
|
||||
} catch (err) {
|
||||
console.error('Failed to load plans:', err);
|
||||
setError('No se pudieron cargar los planes. Por favor, intenta nuevamente.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPrice = (plan: PlanMetadata) => {
|
||||
return billingCycle === 'monthly' ? plan.monthly_price : plan.yearly_price;
|
||||
};
|
||||
|
||||
const getSavings = (plan: PlanMetadata) => {
|
||||
if (billingCycle === 'yearly') {
|
||||
return subscriptionService.calculateYearlySavings(
|
||||
plan.monthly_price,
|
||||
plan.yearly_price
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getPlanIcon = (tier: SubscriptionTier) => {
|
||||
switch (tier) {
|
||||
case SUBSCRIPTION_TIERS.STARTER:
|
||||
return <Package className="w-6 h-6" />;
|
||||
case SUBSCRIPTION_TIERS.PROFESSIONAL:
|
||||
return <TrendingUp className="w-6 h-6" />;
|
||||
case SUBSCRIPTION_TIERS.ENTERPRISE:
|
||||
return <Settings className="w-6 h-6" />;
|
||||
default:
|
||||
return <Package className="w-6 h-6" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatFeatureName = (feature: string): string => {
|
||||
const featureNames: Record<string, string> = {
|
||||
'inventory_management': 'Gestión de inventario',
|
||||
'sales_tracking': 'Seguimiento de ventas',
|
||||
'basic_recipes': 'Recetas básicas',
|
||||
'production_planning': 'Planificación de producción',
|
||||
'basic_reporting': 'Informes básicos',
|
||||
'mobile_app_access': 'Acceso desde app móvil',
|
||||
'email_support': 'Soporte por email',
|
||||
'easy_step_by_step_onboarding': 'Onboarding guiado paso a paso',
|
||||
'basic_forecasting': 'Pronósticos básicos',
|
||||
'demand_prediction': 'Predicción de demanda IA',
|
||||
'waste_tracking': 'Seguimiento de desperdicios',
|
||||
'order_management': 'Gestión de pedidos',
|
||||
'customer_management': 'Gestión de clientes',
|
||||
'supplier_management': 'Gestión de proveedores',
|
||||
'batch_tracking': 'Trazabilidad de lotes',
|
||||
'expiry_alerts': 'Alertas de caducidad',
|
||||
'advanced_analytics': 'Analíticas avanzadas',
|
||||
'custom_reports': 'Informes personalizados',
|
||||
'sales_analytics': 'Análisis de ventas',
|
||||
'supplier_performance': 'Rendimiento de proveedores',
|
||||
'waste_analysis': 'Análisis de desperdicios',
|
||||
'profitability_analysis': 'Análisis de rentabilidad',
|
||||
'weather_data_integration': 'Integración datos meteorológicos',
|
||||
'traffic_data_integration': 'Integración datos de tráfico',
|
||||
'multi_location_support': 'Soporte multi-ubicación',
|
||||
'location_comparison': 'Comparación entre ubicaciones',
|
||||
'inventory_transfer': 'Transferencias de inventario',
|
||||
'batch_scaling': 'Escalado de lotes',
|
||||
'recipe_feasibility_check': 'Verificación de factibilidad',
|
||||
'seasonal_patterns': 'Patrones estacionales',
|
||||
'longer_forecast_horizon': 'Horizonte de pronóstico extendido',
|
||||
'pos_integration': 'Integración POS',
|
||||
'accounting_export': 'Exportación contable',
|
||||
'basic_api_access': 'Acceso API básico',
|
||||
'priority_email_support': 'Soporte prioritario por email',
|
||||
'phone_support': 'Soporte telefónico',
|
||||
'scenario_modeling': 'Modelado de escenarios',
|
||||
'what_if_analysis': 'Análisis what-if',
|
||||
'risk_assessment': 'Evaluación de riesgos',
|
||||
'full_api_access': 'Acceso completo API',
|
||||
'unlimited_webhooks': 'Webhooks ilimitados',
|
||||
'erp_integration': 'Integración ERP',
|
||||
'custom_integrations': 'Integraciones personalizadas',
|
||||
'sso_saml': 'SSO/SAML',
|
||||
'advanced_permissions': 'Permisos avanzados',
|
||||
'audit_logs_export': 'Exportación de logs de auditoría',
|
||||
'compliance_reports': 'Informes de cumplimiento',
|
||||
'dedicated_account_manager': 'Gestor de cuenta dedicado',
|
||||
'priority_support': 'Soporte prioritario',
|
||||
'support_24_7': 'Soporte 24/7',
|
||||
'custom_training': 'Formación personalizada'
|
||||
};
|
||||
|
||||
return featureNames[feature] || feature.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section id="pricing" className="py-24 bg-[var(--bg-primary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
||||
<span className="ml-3 text-[var(--text-secondary)]">Cargando planes...</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !plans) {
|
||||
return (
|
||||
<section id="pricing" 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 py-20">
|
||||
<p className="text-[var(--color-error)]">{error}</p>
|
||||
<Button onClick={loadPlans} className="mt-4">Reintentar</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="pricing" className="py-24 bg-[var(--bg-primary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
|
||||
Planes que se Adaptan a tu Negocio
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl mx-auto text-lg text-[var(--text-secondary)]">
|
||||
Sin costos ocultos, sin compromisos largos. Comienza gratis y escala según crezcas.
|
||||
</p>
|
||||
|
||||
{/* Billing Cycle Toggle */}
|
||||
<div className="mt-8 inline-flex rounded-lg border-2 border-[var(--border-primary)] p-1 bg-[var(--bg-secondary)]">
|
||||
<button
|
||||
onClick={() => setBillingCycle('monthly')}
|
||||
className={`px-6 py-2 rounded-md text-sm font-semibold transition-all ${
|
||||
billingCycle === 'monthly'
|
||||
? 'bg-[var(--color-primary)] text-white shadow-md'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
Mensual
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingCycle('yearly')}
|
||||
className={`px-6 py-2 rounded-md text-sm font-semibold transition-all flex items-center gap-2 ${
|
||||
billingCycle === 'yearly'
|
||||
? 'bg-[var(--color-primary)] text-white shadow-md'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
Anual
|
||||
<span className="text-xs font-bold text-green-600 dark:text-green-400">
|
||||
Ahorra 17%
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plans Grid */}
|
||||
<div className="mt-16 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{Object.entries(plans).map(([tier, plan]) => {
|
||||
const price = getPrice(plan);
|
||||
const savings = getSavings(plan);
|
||||
const isPopular = plan.popular;
|
||||
const tierKey = tier as SubscriptionTier;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier}
|
||||
className={`
|
||||
group relative rounded-3xl p-8 transition-all duration-300
|
||||
${isPopular
|
||||
? 'bg-gradient-to-br from-[var(--color-primary)] via-[var(--color-primary)] to-[var(--color-primary-dark)] shadow-2xl transform scale-105 z-10'
|
||||
: 'bg-[var(--bg-secondary)] border-2 border-[var(--border-primary)] hover:border-[var(--color-primary)]/30 hover:shadow-xl hover:-translate-y-1'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Popular Badge */}
|
||||
{isPopular && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<div className="bg-gradient-to-r from-[var(--color-secondary)] to-[var(--color-secondary-dark)] text-white px-6 py-2 rounded-full text-sm font-bold shadow-lg flex items-center gap-1">
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
Más Popular
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div className="absolute top-6 right-6">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
|
||||
isPopular
|
||||
? 'bg-white/10 text-white'
|
||||
: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
|
||||
}`}>
|
||||
{getPlanIcon(tierKey)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className={`mb-6 ${isPopular ? 'pt-4' : ''}`}>
|
||||
<h3 className={`text-2xl font-bold ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
{plan.name}
|
||||
</h3>
|
||||
<p className={`mt-3 leading-relaxed ${isPopular ? 'text-white/90' : 'text-[var(--text-secondary)]'}`}>
|
||||
{plan.tagline}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-baseline">
|
||||
<span className={`text-5xl font-bold ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
{subscriptionService.formatPrice(price)}
|
||||
</span>
|
||||
<span className={`ml-2 text-lg ${isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}`}>
|
||||
/{billingCycle === 'monthly' ? 'mes' : 'año'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Savings Badge */}
|
||||
{savings && (
|
||||
<div className={`mt-2 px-3 py-1 text-sm font-medium rounded-full inline-block ${
|
||||
isPopular ? 'bg-white/20 text-white' : 'bg-green-500/10 text-green-600 dark:text-green-400'
|
||||
}`}>
|
||||
Ahorra {subscriptionService.formatPrice(savings.savingsAmount)}/año
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trial Badge */}
|
||||
{!savings && (
|
||||
<div className={`mt-2 px-3 py-1 text-sm font-medium rounded-full inline-block ${
|
||||
isPopular ? 'bg-white/20 text-white' : 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
|
||||
}`}>
|
||||
{plan.trial_days} días gratis
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Key Limits */}
|
||||
<div className={`mb-6 p-4 rounded-lg ${
|
||||
isPopular ? 'bg-white/10' : 'bg-[var(--bg-primary)]'
|
||||
}`}>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className={isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}>Usuarios:</span>
|
||||
<span className={`font-semibold ml-2 ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
{plan.limits.users || 'Ilimitado'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}>Ubicaciones:</span>
|
||||
<span className={`font-semibold ml-2 ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
{plan.limits.locations || 'Ilimitado'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}>Productos:</span>
|
||||
<span className={`font-semibold ml-2 ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
{plan.limits.products || 'Ilimitado'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}>Pronósticos/día:</span>
|
||||
<span className={`font-semibold ml-2 ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
{plan.limits.forecasts_per_day || 'Ilimitado'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features List (first 8) */}
|
||||
<div className={`space-y-3 mb-8 ${isPopular ? 'max-h-80' : 'max-h-72'} overflow-y-auto pr-2 scrollbar-thin`}>
|
||||
{plan.features.slice(0, 8).map((feature) => (
|
||||
<div key={feature} className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${
|
||||
isPopular
|
||||
? 'bg-white'
|
||||
: 'bg-[var(--color-success)]'
|
||||
}`}>
|
||||
<Check className={`w-3 h-3 ${isPopular ? 'text-[var(--color-primary)]' : 'text-white'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<span className={`ml-3 text-sm font-medium ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
{formatFeatureName(feature)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{plan.features.length > 8 && (
|
||||
<p className={`text-sm italic ${isPopular ? 'text-white/70' : 'text-[var(--text-secondary)]'}`}>
|
||||
Y {plan.features.length - 8} características más...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Support */}
|
||||
<div className={`mb-6 text-sm text-center border-t pt-4 ${
|
||||
isPopular ? 'text-white/80 border-white/20' : 'text-[var(--text-secondary)] border-[var(--border-primary)]'
|
||||
}`}>
|
||||
{plan.support}
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Link to={plan.contact_sales ? '/contact' : `/register?plan=${tier}`}>
|
||||
<Button
|
||||
className={`w-full py-4 text-base font-semibold transition-all duration-200 ${
|
||||
isPopular
|
||||
? 'bg-white text-[var(--color-primary)] hover:bg-gray-100 shadow-lg hover:shadow-xl'
|
||||
: 'border-2 border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white'
|
||||
}`}
|
||||
variant={isPopular ? 'primary' : 'outline'}
|
||||
>
|
||||
{plan.contact_sales ? 'Contactar Ventas' : 'Comenzar Prueba Gratuita'}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<p className={`text-xs text-center mt-3 ${isPopular ? 'text-white/70' : 'text-[var(--text-secondary)]'}`}>
|
||||
{plan.trial_days} días gratis • Sin tarjeta requerida
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Feature Comparison Link */}
|
||||
<div className="text-center mt-12">
|
||||
<Link
|
||||
to="/plans/compare"
|
||||
className="text-[var(--color-primary)] hover:text-[var(--color-primary-dark)] font-semibold inline-flex items-center gap-2"
|
||||
>
|
||||
Ver comparación completa de características
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
1
frontend/src/components/subscription/index.ts
Normal file
1
frontend/src/components/subscription/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PricingSection } from './PricingSection';
|
||||
@@ -95,7 +95,15 @@
|
||||
"deployment": "Deployment"
|
||||
},
|
||||
"estimated_time": "Estimated time: {{minutes}} minutes",
|
||||
"description": "We're creating a personalized AI model for your bakery based on your historical data."
|
||||
"estimated_time_remaining": "Estimated time remaining: {{time}}",
|
||||
"description": "We're creating a personalized AI model for your bakery based on your historical data.",
|
||||
"skip_to_dashboard": {
|
||||
"title": "Taking too long?",
|
||||
"description": "Training continues in the background. You can go to the dashboard now and explore your system while the model finishes training.",
|
||||
"button": "Go to Dashboard",
|
||||
"info": "Training is taking longer than expected. Don't worry, you can explore your dashboard while the model finishes training in the background.",
|
||||
"training_continues": "Training continues in the background"
|
||||
}
|
||||
},
|
||||
"completion": {
|
||||
"title": "Setup Complete!",
|
||||
|
||||
@@ -95,7 +95,15 @@
|
||||
"deployment": "Despliegue"
|
||||
},
|
||||
"estimated_time": "Tiempo estimado: {{minutes}} minutos",
|
||||
"description": "Estamos creando un modelo de IA personalizado para tu panadería basado en tus datos históricos."
|
||||
"estimated_time_remaining": "Tiempo restante estimado: {{time}}",
|
||||
"description": "Estamos creando un modelo de IA personalizado para tu panadería basado en tus datos históricos.",
|
||||
"skip_to_dashboard": {
|
||||
"title": "¿Toma demasiado tiempo?",
|
||||
"description": "El entrenamiento continúa en segundo plano. Puedes ir al dashboard ahora y explorar tu sistema mientras el modelo termina de entrenarse.",
|
||||
"button": "Ir al Dashboard",
|
||||
"info": "El entrenamiento está tardando más de lo esperado. No te preocupes, puedes explorar tu dashboard mientras el modelo termina de entrenarse en segundo plano.",
|
||||
"training_continues": "El entrenamiento continúa en segundo plano"
|
||||
}
|
||||
},
|
||||
"completion": {
|
||||
"title": "¡Configuración Completa!",
|
||||
|
||||
@@ -95,7 +95,15 @@
|
||||
"deployment": "Hedapena"
|
||||
},
|
||||
"estimated_time": "Aurreikusitako denbora: {{minutes}} minutu",
|
||||
"description": "AA modelo pertsonalizatu bat sortzen ari gara zure okindegiarentzat zure datu historikoen oinarrian."
|
||||
"estimated_time_remaining": "Geratzen den denbora aurreikusia: {{time}}",
|
||||
"description": "AA modelo pertsonalizatu bat sortzen ari gara zure okindegiarentzat zure datu historikoen oinarrian.",
|
||||
"skip_to_dashboard": {
|
||||
"title": "Denbora luzea hartzen al du?",
|
||||
"description": "Prestakuntza atzeko planoan jarraitzen du. Panelera joan zaitezke orain eta sistema arakatu modeloa entrenatzen amaitzen duen bitartean.",
|
||||
"button": "Panelera Joan",
|
||||
"info": "Prestakuntza espero baino denbora gehiago hartzen ari da. Ez kezkatu, zure panela arakatu dezakezu modeloa atzeko planoan entrenatzen amaitzen duen bitartean.",
|
||||
"training_continues": "Prestakuntza atzeko planoan jarraitzen du"
|
||||
}
|
||||
},
|
||||
"completion": {
|
||||
"title": "Konfigurazioa Osatuta!",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, CheckCircle, ArrowRight, Star, ExternalLink, Download, CreditCard, X } from 'lucide-react';
|
||||
import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, CheckCircle, ArrowRight, Star, ExternalLink, Download, CreditCard, X, Activity, Database, Zap, HardDrive, ShoppingCart, ChefHat } from 'lucide-react';
|
||||
import { Button, Card, Badge, Modal } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
@@ -40,15 +40,16 @@ const SubscriptionPage: React.FC = () => {
|
||||
setSubscriptionLoading(true);
|
||||
const [usage, plans] = await Promise.all([
|
||||
subscriptionService.getUsageSummary(tenantId),
|
||||
subscriptionService.getAvailablePlans()
|
||||
subscriptionService.fetchAvailablePlans()
|
||||
]);
|
||||
|
||||
// FIX: Handle demo mode or missing subscription data
|
||||
if (!usage || !usage.usage) {
|
||||
// If no usage data, likely a demo tenant - create mock data
|
||||
const mockUsage: UsageSummary = {
|
||||
plan: 'demo',
|
||||
plan: 'starter',
|
||||
status: 'active',
|
||||
billing_cycle: 'monthly',
|
||||
monthly_price: 0,
|
||||
next_billing_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
usage: {
|
||||
@@ -69,6 +70,42 @@ const SubscriptionPage: React.FC = () => {
|
||||
limit: 50,
|
||||
unlimited: false,
|
||||
usage_percentage: 0
|
||||
},
|
||||
recipes: {
|
||||
current: 0,
|
||||
limit: 50,
|
||||
unlimited: false,
|
||||
usage_percentage: 0
|
||||
},
|
||||
suppliers: {
|
||||
current: 0,
|
||||
limit: 20,
|
||||
unlimited: false,
|
||||
usage_percentage: 0
|
||||
},
|
||||
training_jobs_today: {
|
||||
current: 0,
|
||||
limit: 1,
|
||||
unlimited: false,
|
||||
usage_percentage: 0
|
||||
},
|
||||
forecasts_today: {
|
||||
current: 0,
|
||||
limit: 10,
|
||||
unlimited: false,
|
||||
usage_percentage: 0
|
||||
},
|
||||
api_calls_this_hour: {
|
||||
current: 0,
|
||||
limit: 100,
|
||||
unlimited: false,
|
||||
usage_percentage: 0
|
||||
},
|
||||
file_storage_used_gb: {
|
||||
current: 0,
|
||||
limit: 5,
|
||||
unlimited: false,
|
||||
usage_percentage: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -313,68 +350,217 @@ const SubscriptionPage: React.FC = () => {
|
||||
<TrendingUp className="w-5 h-5 mr-2 text-orange-500" />
|
||||
Uso de Recursos
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Users */}
|
||||
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/10 rounded-lg border border-blue-500/20">
|
||||
<Users className="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
<span className="font-medium text-[var(--text-primary)]">Usuarios</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.users.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Locations */}
|
||||
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-500/10 rounded-lg border border-green-500/20">
|
||||
<MapPin className="w-4 h-4 text-green-500" />
|
||||
{/* Team & Organization Metrics */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-semibold text-[var(--text-secondary)] mb-4 uppercase tracking-wide">Equipo & Organización</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Users */}
|
||||
<div className="space-y-3 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/10 rounded-lg border border-blue-500/20">
|
||||
<Users className="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
<span className="font-medium text-[var(--text-primary)]">Usuarios</span>
|
||||
</div>
|
||||
<span className="font-medium text-[var(--text-primary)]">Ubicaciones</span>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.users.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.locations.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}</span>
|
||||
</span>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Products */}
|
||||
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-500/10 rounded-lg border border-purple-500/20">
|
||||
<Package className="w-4 h-4 text-purple-500" />
|
||||
{/* Locations */}
|
||||
<div className="space-y-3 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-500/10 rounded-lg border border-green-500/20">
|
||||
<MapPin className="w-4 h-4 text-green-500" />
|
||||
</div>
|
||||
<span className="font-medium text-[var(--text-primary)]">Ubicaciones</span>
|
||||
</div>
|
||||
<span className="font-medium text-[var(--text-primary)]">Productos</span>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.locations.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.products.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}</span>
|
||||
</span>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product & Inventory Metrics */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-semibold text-[var(--text-secondary)] mb-4 uppercase tracking-wide">Productos & Inventario</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Products */}
|
||||
<div className="space-y-3 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-500/10 rounded-lg border border-purple-500/20">
|
||||
<Package className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<span className="font-medium text-[var(--text-primary)]">Productos</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.products.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Recipes */}
|
||||
<div className="space-y-3 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-500/10 rounded-lg border border-amber-500/20">
|
||||
<ChefHat className="w-4 h-4 text-amber-500" />
|
||||
</div>
|
||||
<span className="font-medium text-[var(--text-primary)]">Recetas</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.recipes.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.recipes.unlimited ? '∞' : usageSummary.usage.recipes.limit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Suppliers */}
|
||||
<div className="space-y-3 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-teal-500/10 rounded-lg border border-teal-500/20">
|
||||
<ShoppingCart className="w-4 h-4 text-teal-500" />
|
||||
</div>
|
||||
<span className="font-medium text-[var(--text-primary)]">Proveedores</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.suppliers.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.suppliers.unlimited ? '∞' : usageSummary.usage.suppliers.limit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ML & Analytics Metrics (Daily) */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-semibold text-[var(--text-secondary)] mb-4 uppercase tracking-wide">IA & Analíticas (Uso Diario)</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Training Jobs Today */}
|
||||
<div className="space-y-3 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-indigo-500/10 rounded-lg border border-indigo-500/20">
|
||||
<Database className="w-4 h-4 text-indigo-500" />
|
||||
</div>
|
||||
<span className="font-medium text-[var(--text-primary)]">Entrenamientos IA Hoy</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.training_jobs_today.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.training_jobs_today.unlimited ? '∞' : usageSummary.usage.training_jobs_today.limit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Forecasts Today */}
|
||||
<div className="space-y-3 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-pink-500/10 rounded-lg border border-pink-500/20">
|
||||
<TrendingUp className="w-4 h-4 text-pink-500" />
|
||||
</div>
|
||||
<span className="font-medium text-[var(--text-primary)]">Pronósticos Hoy</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.forecasts_today.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.forecasts_today.unlimited ? '∞' : usageSummary.usage.forecasts_today.limit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API & Storage Metrics */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-[var(--text-secondary)] mb-4 uppercase tracking-wide">API & Almacenamiento</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* API Calls This Hour */}
|
||||
<div className="space-y-3 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-500/10 rounded-lg border border-orange-500/20">
|
||||
<Zap className="w-4 h-4 text-orange-500" />
|
||||
</div>
|
||||
<span className="font-medium text-[var(--text-primary)]">Llamadas API (Esta Hora)</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.api_calls_this_hour.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.api_calls_this_hour.unlimited ? '∞' : usageSummary.usage.api_calls_this_hour.limit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File Storage */}
|
||||
<div className="space-y-3 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-cyan-500/10 rounded-lg border border-cyan-500/20">
|
||||
<HardDrive className="w-4 h-4 text-cyan-500" />
|
||||
</div>
|
||||
<span className="font-medium text-[var(--text-primary)]">Almacenamiento</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.file_storage_used_gb.current.toFixed(2)}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.file_storage_used_gb.unlimited ? '∞' : `${usageSummary.usage.file_storage_used_gb.limit} GB`}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
<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' : 'Ilimitado'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../components/ui';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
import { PricingSection } from '../../components/subscription';
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
@@ -551,336 +552,7 @@ const LandingPage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<section id="pricing" 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">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
|
||||
Planes que se Adaptan a tu Negocio
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl mx-auto text-lg text-[var(--text-secondary)]">
|
||||
Sin costos ocultos, sin compromisos largos. Comienza gratis y escala según crezcas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Starter Plan */}
|
||||
<div className="group relative bg-[var(--bg-secondary)] rounded-3xl p-8 border-2 border-[var(--border-primary)] hover:border-[var(--color-primary)]/30 transition-all duration-300 hover:shadow-xl hover:-translate-y-1">
|
||||
<div className="absolute top-6 right-6">
|
||||
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<Package className="w-6 h-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-2xl font-bold text-[var(--text-primary)]">Starter</h3>
|
||||
<p className="mt-3 text-[var(--text-secondary)] leading-relaxed">Ideal para panaderías pequeñas o nuevas</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-5xl font-bold text-[var(--text-primary)]">€49</span>
|
||||
<span className="ml-2 text-lg text-[var(--text-secondary)]">/mes</span>
|
||||
</div>
|
||||
<div className="mt-2 px-3 py-1 bg-[var(--color-success)]/10 text-[var(--color-success)] text-sm font-medium rounded-full inline-block">
|
||||
14 días gratis
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Hasta 50 productos</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Control de inventario básico</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Predicción básica de demanda</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Reportes básicos de producción</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Analytics básicos</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">1 ubicación</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Soporte por email</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button className="w-full py-4 text-base font-semibold border-2 border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white transition-all duration-200 group-hover:shadow-lg" variant="outline">
|
||||
Comenzar Gratis
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Professional Plan - Highlighted */}
|
||||
<div className="group relative bg-gradient-to-br from-[var(--color-primary)] via-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-3xl p-8 shadow-2xl transform scale-105 z-10">
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<div className="bg-gradient-to-r from-[var(--color-secondary)] to-[var(--color-secondary-dark)] text-white px-6 py-2 rounded-full text-sm font-bold shadow-lg">
|
||||
⭐ Más Popular
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-6 right-6">
|
||||
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center">
|
||||
<TrendingUp className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 pt-4">
|
||||
<h3 className="text-2xl font-bold text-white">Professional</h3>
|
||||
<p className="mt-3 text-white/90 leading-relaxed">Ideal para panaderías y cadenas en crecimiento</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-5xl font-bold text-white">€129</span>
|
||||
<span className="ml-2 text-lg text-white/80">/mes</span>
|
||||
</div>
|
||||
<div className="mt-2 px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full inline-block">
|
||||
14 días gratis
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-white font-medium">Productos ilimitados</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-white font-medium">Control de inventario avanzado</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-white font-medium">IA Avanzada con 92% de precisión</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-white font-medium">Gestión completa de producción</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-white font-medium">POS integrado</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-white font-medium">Gestión de Logística Básica</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-white font-medium">Analytics avanzados</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-white font-medium">1-2 ubicaciones</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-white font-medium">Soporte prioritario 24/7</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button className="w-full py-4 text-base font-semibold bg-white text-[var(--color-primary)] hover:bg-gray-100 transition-all duration-200 shadow-lg hover:shadow-xl">
|
||||
Comenzar Prueba Gratuita
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Enterprise Plan */}
|
||||
<div className="group relative bg-[var(--bg-secondary)] rounded-3xl p-8 border-2 border-[var(--border-primary)] hover:border-[var(--color-accent)]/30 transition-all duration-300 hover:shadow-xl hover:-translate-y-1">
|
||||
<div className="absolute top-6 right-6">
|
||||
<div className="w-12 h-12 bg-[var(--color-accent)]/10 rounded-full flex items-center justify-center">
|
||||
<Settings className="w-6 h-6 text-[var(--color-accent)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-2xl font-bold text-[var(--text-primary)]">Enterprise</h3>
|
||||
<p className="mt-3 text-[var(--text-secondary)] leading-relaxed">Ideal para cadenas con obradores centrales</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-5xl font-bold text-[var(--text-primary)]">€399</span>
|
||||
<span className="ml-2 text-lg text-[var(--text-secondary)]">/mes</span>
|
||||
</div>
|
||||
<div className="mt-2 px-3 py-1 bg-[var(--color-accent)]/10 text-[var(--color-accent)] text-sm font-medium rounded-full inline-block">
|
||||
Demo personalizada
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-8 max-h-80 overflow-y-auto pr-2 scrollbar-thin">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Productos ilimitados</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Control de inventario multi-locación</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">IA personalizada por ubicación</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Optimización de capacidad</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Integración con ERPs</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Gestión de Logística Avanzada</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Analytics predictivos</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Ubicaciones y obradores ilimitados</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">API Personalizada</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Manager de Cuenta Dedicado</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button className="w-full py-4 text-base font-semibold border-2 border-[var(--color-accent)] text-[var(--color-accent)] hover:bg-[var(--color-accent)] hover:text-white transition-all duration-200 group-hover:shadow-lg" variant="outline">
|
||||
Contactar Ventas
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<p className="text-sm text-[var(--text-tertiary)]">
|
||||
🔒 Todos los planes incluyen cifrado de datos, backups automáticos y cumplimiento RGPD
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<PricingSection />
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section className="py-24 bg-[var(--bg-secondary)]">
|
||||
|
||||
Reference in New Issue
Block a user