diff --git a/frontend/src/api/services/tenant.ts b/frontend/src/api/services/tenant.ts index 9d5ada07..e173a8d1 100644 --- a/frontend/src/api/services/tenant.ts +++ b/frontend/src/api/services/tenant.ts @@ -86,8 +86,7 @@ export class TenantService { return apiClient.get(`${this.baseUrl}/${parentTenantId}/children`); } - async bulkCreateChildTenants(request: { - parent_tenant_id: string; + async bulkCreateChildTenants(parentTenantId: string, request: { child_tenants: Array<{ name: string; city: string; @@ -99,6 +98,10 @@ export class TenantService { longitude?: number; phone?: string; email?: string; + business_type?: string; + business_model?: string; + timezone?: string; + metadata?: Record; }>; auto_configure_distribution?: boolean; }): Promise<{ @@ -109,7 +112,7 @@ export class TenantService { failed_tenants: Array<{ name: string; location_code: string; error: string }>; distribution_configured: boolean; }> { - return apiClient.post(`${this.baseUrl}/bulk-children`, request); + return apiClient.post(`${this.baseUrl}/${parentTenantId}/bulk-children`, request); } // =================================================================== diff --git a/frontend/src/components/domain/auth/RegisterForm.tsx b/frontend/src/components/domain/auth/RegisterForm.tsx index 67a77a5b..e4692251 100644 --- a/frontend/src/components/domain/auth/RegisterForm.tsx +++ b/frontend/src/components/domain/auth/RegisterForm.tsx @@ -154,7 +154,7 @@ export const RegisterForm: React.FC = ({ } if (!formData.acceptTerms) { - newErrors.acceptTerms = t('auth:validation.terms_required', 'Debes aceptar los términos y condiciones'); + newErrors.acceptTerms = t('auth:validation.terms_required', 'Debes aceptar los términos y condiciones y la política de privacidad'); } setErrors(newErrors); @@ -235,7 +235,7 @@ export const RegisterForm: React.FC = ({ const handlePaymentError = (errorMessage: string) => { showToast.error(errorMessage, { - title: 'Error en el pago' + title: t('auth:alerts.payment_error', 'Error en el pago') }); }; @@ -252,13 +252,13 @@ export const RegisterForm: React.FC = ({ // Show 2 steps if plan is pre-selected, 3 steps otherwise const steps = preSelectedPlan ? [ - { key: 'basic_info', label: 'Información', number: 1, time: '2 min' }, - { key: 'payment', label: 'Pago', number: 2, time: '2 min' } + { key: 'basic_info', label: t('auth:steps.info', 'Información'), number: 1, time: '2 min' }, + { key: 'payment', label: t('auth:steps.payment', 'Pago'), number: 2, time: '2 min' } ] : [ - { key: 'basic_info', label: 'Información', number: 1, time: '2 min' }, - { key: 'subscription', label: 'Plan', number: 2, time: '1 min' }, - { key: 'payment', label: 'Pago', number: 3, time: '2 min' } + { key: 'basic_info', label: t('auth:steps.info', 'Información'), number: 1, time: '2 min' }, + { key: 'subscription', label: t('auth:steps.subscription', 'Plan'), number: 2, time: '1 min' }, + { key: 'payment', label: t('auth:steps.payment', 'Pago'), number: 3, time: '2 min' } ]; const getStepIndex = (step: RegistrationStep) => { @@ -499,14 +499,8 @@ export const RegisterForm: React.FC = ({ disabled={isLoading} /> @@ -524,7 +518,7 @@ export const RegisterForm: React.FC = ({ disabled={isLoading} /> @@ -538,7 +532,7 @@ export const RegisterForm: React.FC = ({ disabled={isLoading} /> @@ -551,7 +545,7 @@ export const RegisterForm: React.FC = ({ disabled={isLoading || !validatePassword(formData.password) || !formData.acceptTerms || passwordMatchStatus !== 'match'} className="w-full sm:w-48" > - Siguiente + {t('auth:register.next_button', 'Siguiente')} @@ -587,7 +581,7 @@ export const RegisterForm: React.FC = ({ disabled={isLoading} className="w-full sm:w-48 order-2 sm:order-1" > - Anterior + {t('auth:register.previous_button', 'Anterior')} @@ -619,36 +613,36 @@ export const RegisterForm: React.FC = ({

- Resumen de tu Plan + {t('auth:payment.payment_summary', 'Resumen de tu Plan')}

- Plan seleccionado: + {t('auth:payment.selected_plan', 'Plan seleccionado:')} {selectedPlanMetadata.name}
- Precio mensual: + {t('auth:payment.monthly_price', 'Precio mensual:')} {subscriptionService.formatPrice(selectedPlanMetadata.monthly_price)}/mes
{useTrial && (
- Período de prueba: + {t('auth:payment.trial_period', 'Período de prueba:')} - {isPilot ? `${trialMonths} meses GRATIS` : '14 días gratis'} + {isPilot ? t('auth:payment.free_months', {count: trialMonths}) : t('auth:payment.free_days', '14 días gratis')}
)}
- Total hoy: + {t('auth:payment.total_today', 'Total hoy:')} €0.00

{useTrial - ? `Se te cobrará ${subscriptionService.formatPrice(selectedPlanMetadata.monthly_price)} después del período de prueba` - : 'Tarjeta requerida para validación' + ? t('auth:payment.billing_message', {price: subscriptionService.formatPrice(selectedPlanMetadata.monthly_price)}) + : t('auth:payment.payment_required', 'Tarjeta requerida para validación') }

@@ -675,7 +669,7 @@ export const RegisterForm: React.FC = ({ disabled={isLoading} className="w-full sm:w-48" > - Anterior + {t('auth:register.previous_button', 'Anterior')}
@@ -701,7 +695,7 @@ export const RegisterForm: React.FC = ({ {onLoginClick && currentStep === 'basic_info' && (

- ¿Ya tienes una cuenta? + {t('auth:register.have_account', '¿Ya tienes una cuenta?')}

)} diff --git a/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx b/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx index 140df45d..bb333348 100644 --- a/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx +++ b/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx @@ -422,8 +422,7 @@ const OnboardingWizardContent: React.FC = () => { throw new Error('Parent tenant not registered'); } - const response = await tenantService.bulkCreateChildTenants({ - parent_tenant_id: parentTenantId, + const response = await tenantService.bulkCreateChildTenants(parentTenantId, { child_tenants: data.childTenants.map((ct: any) => ({ name: ct.name, city: ct.city, @@ -435,6 +434,10 @@ const OnboardingWizardContent: React.FC = () => { longitude: ct.longitude, phone: ct.phone, email: ct.email, + business_type: ct.business_type, + business_model: ct.business_model, + timezone: ct.timezone, + metadata: ct.metadata, })), auto_configure_distribution: true, }); @@ -446,6 +449,11 @@ const OnboardingWizardContent: React.FC = () => { if (response.failed_count > 0) { console.warn('⚠️ Some child tenants failed to create:', response.failed_tenants); + + // Show specific errors for each failed tenant + response.failed_tenants.forEach(failed => { + console.error(`Failed to create tenant ${failed.name} (${failed.location_code}):`, failed.error); + }); } } catch (childTenantError) { console.error('❌ Failed to create child tenants:', childTenantError); diff --git a/frontend/src/components/domain/onboarding/steps/ChildTenantsSetupStep.tsx b/frontend/src/components/domain/onboarding/steps/ChildTenantsSetupStep.tsx index 7494eb87..b7f923b0 100644 --- a/frontend/src/components/domain/onboarding/steps/ChildTenantsSetupStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/ChildTenantsSetupStep.tsx @@ -1,10 +1,12 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Plus, Store, MapPin, Trash2, Edit2, Building2, X } from 'lucide-react'; +import { Plus, Store, MapPin, Trash2, Edit2, Building2, X, Phone, Mail, Clock, Tag, Globe } from 'lucide-react'; import Button from '../../../ui/Button/Button'; import Card from '../../../ui/Card/Card'; import { Modal, ModalHeader, ModalBody, ModalFooter } from '../../../ui/Modal'; import { Input } from '../../../ui/Input'; +import { Select } from '../../../ui/Select'; +import { AddressAutocomplete } from '../../../ui/AddressAutocomplete'; export interface ChildTenantSetupStepProps { onUpdate?: (data: { childTenants: ChildTenant[]; canContinue: boolean }) => void; @@ -22,6 +24,14 @@ export interface ChildTenant { address: string; postal_code: string; location_code: string; + latitude?: number; + longitude?: number; + phone?: string; + email?: string; + business_type?: string; + business_model?: string; + timezone?: string; + metadata?: Record; } export const ChildTenantsSetupStep: React.FC = ({ @@ -42,6 +52,14 @@ export const ChildTenantsSetupStep: React.FC = ({ address: '', postal_code: '', location_code: '', + latitude: undefined, + longitude: undefined, + phone: '', + email: '', + business_type: 'bakery', + business_model: 'retail_bakery', + timezone: 'Europe/Madrid', + metadata: {}, }); const [formErrors, setFormErrors] = useState>({}); @@ -67,11 +85,35 @@ export const ChildTenantsSetupStep: React.FC = ({ } if (!formData.postal_code?.trim()) { errors.postal_code = 'El código postal es requerido'; + } else if (!/^\d{5}$/.test(formData.postal_code)) { + errors.postal_code = 'El código postal debe tener exactamente 5 dígitos'; } if (!formData.location_code?.trim()) { errors.location_code = 'El código de ubicación es requerido'; } else if (formData.location_code.length > 10) { errors.location_code = 'El código no debe exceder 10 caracteres'; + } else if (!/^[A-Z0-9\-_.]+$/.test(formData.location_code)) { + errors.location_code = 'Solo se permiten letras mayúsculas, números y guiones/guiones bajos'; + } + + // Phone validation + if (formData.phone && formData.phone.trim()) { + const phone = formData.phone.replace(/[\s\-\(\)]/g, ''); + const patterns = [ + /^(\+34|0034|34)?[6789]\d{8}$/, // Mobile + /^(\+34|0034|34)?9\d{8}$/ // Landline + ]; + if (!patterns.some(pattern => pattern.test(phone))) { + errors.phone = 'Introduce un número de teléfono español válido'; + } + } + + // Email validation + if (formData.email && formData.email.trim()) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(formData.email)) { + errors.email = 'Introduce un correo electrónico válido'; + } } setFormErrors(errors); @@ -122,6 +164,14 @@ export const ChildTenantsSetupStep: React.FC = ({ address: formData.address!, postal_code: formData.postal_code!, location_code: formData.location_code!.toUpperCase(), + latitude: formData.latitude, + longitude: formData.longitude, + phone: formData.phone || undefined, + email: formData.email || undefined, + business_type: formData.business_type || 'bakery', + business_model: formData.business_model || 'retail_bakery', + timezone: formData.timezone || 'Europe/Madrid', + metadata: formData.metadata || {}, }; if (editingTenant) { @@ -236,9 +286,16 @@ export const ChildTenantsSetupStep: React.FC = ({

{tenant.name}

- - {tenant.location_code} - +
+ + {tenant.location_code} + + {tenant.zone && ( + + • {tenant.zone} + + )} +
@@ -336,7 +393,142 @@ export const ChildTenantsSetupStep: React.FC = ({

- {/* City and Zone */} + {/* Business Type */} +
+ + +
+ + {/* Business Model */} +
+ + +
+ + {/* Contact Info */} +
+
+ +
+ + setFormData({ ...formData, phone: e.target.value })} + placeholder="ej. +34 123 456 789" + error={formErrors.phone} + className="pl-10" + /> +
+
+
+ +
+ + setFormData({ ...formData, email: e.target.value })} + placeholder="ej. contacto@panaderia.com" + error={formErrors.email} + className="pl-10" + /> +
+
+
+ + {/* Timezone */} +
+ +
+ + +
+
+ + {/* Zone */} +
+ + setFormData({ ...formData, zone: e.target.value })} + placeholder="ej. Salamanca, Chamberí, Centro" + /> +

+ Zona o barrio específico dentro de la ciudad +

+
+ + {/* Address with Autocomplete */} +
+ + { + setFormData(prev => ({ + ...prev, + address: address.display_name, + city: address.address.city || address.address.municipality || address.address.suburb || prev.city, + postal_code: address.address.postcode || prev.postal_code, + latitude: address.lat, + longitude: address.lon, + })); + }} + onCoordinatesChange={(lat, lon) => { + setFormData(prev => ({ + ...prev, + latitude: lat, + longitude: lon, + })); + }} + countryCode="es" + required + /> + {formErrors.address && ( +
+ {formErrors.address} +
+ )} +
+ + {/* City and Postal Code */}
setFormData({ ...formData, zone: e.target.value })} - placeholder="ej. Salamanca" + value={formData.postal_code || ''} + onChange={(e) => setFormData({ ...formData, postal_code: e.target.value })} + placeholder="ej. 28001" + error={formErrors.postal_code} + maxLength={5} />
- - {/* Address */} -
- - setFormData({ ...formData, address: e.target.value })} - placeholder="ej. Calle de Serrano, 48" - error={formErrors.address} - /> -
- - {/* Postal Code */} -
- - setFormData({ ...formData, postal_code: e.target.value })} - placeholder="ej. 28001" - error={formErrors.postal_code} - /> -
diff --git a/frontend/src/locales/en/auth.json b/frontend/src/locales/en/auth.json index d62971f1..686c360c 100644 --- a/frontend/src/locales/en/auth.json +++ b/frontend/src/locales/en/auth.json @@ -35,7 +35,9 @@ "passwords_dont_match": "Passwords don't match", "accept_terms": "I accept the terms and conditions", "accept_privacy": "I accept the privacy policy", + "accept_terms_and_privacy": "I accept the terms and conditions and privacy policy", "marketing_consent": "I want to receive newsletters and updates (optional)", + "analytics_consent": "I agree to the use of analytical cookies to improve the experience", "register_button": "Create account", "registering": "Creating account...", "have_account": "Already have an account?", @@ -44,7 +46,48 @@ "privacy_link": "Privacy Policy", "step_of": "Step {current} of {total}", "continue": "Continue", - "back": "Back" + "back": "Back", + "next_button": "Next", + "previous_button": "Previous", + "have_account": "Already have an account?", + "sign_in_link": "Sign in" + }, + "steps": { + "info": "Information", + "subscription": "Plan", + "payment": "Payment" + }, + "subscription": { + "select_plan": "Select your plan", + "choose_plan": "Choose the plan that best fits your business" + }, + "payment": { + "payment_summary": "Your Plan Summary", + "selected_plan": "Selected plan:", + "monthly_price": "Monthly price:", + "trial_period": "Trial period:", + "total_today": "Total today:", + "payment_required": "Payment required for validation", + "billing_message": "You will be charged {price} after the trial period", + "free_months": "{count} months FREE", + "free_days": "14 days free", + "payment_info": "Payment information", + "secure_payment": "Your payment information is protected with end-to-end encryption", + "dev_mode": "Development Mode", + "cardholder_name": "Cardholder name", + "email": "Email address", + "address_line1": "Address", + "city": "City", + "state": "State/Province", + "postal_code": "Postal Code", + "country": "Country", + "card_details": "Card details", + "card_info_secure": "Your card information is secure" + }, + "alerts": { + "success_create": "Account created successfully", + "error_create": "Error creating account", + "payment_error": "Payment error" }, "forgot_password": { "title": "Reset password", @@ -124,14 +167,14 @@ "email_required": "Email address is required", "email_invalid": "Please enter a valid email address", "password_required": "Password is required", - "password_min_length": "Password must be at least {{min}} characters", + "password_min_length": "Password must be at least {min} characters", "password_weak": "Password is too weak", "passwords_must_match": "Passwords must match", "first_name_required": "First name is required", "last_name_required": "Last name is required", "phone_required": "Phone is required", "company_name_required": "Company name is required", - "terms_required": "You must accept the terms and conditions", + "terms_required": "You must accept the terms and conditions and privacy policy", "field_required": "This field is required", "invalid_format": "Invalid format" }, diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index ab790d45..ce8a2c6c 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -441,5 +441,31 @@ "features": "Features", "about": "About", "contact": "Contact" + }, + "messages": { + "saved": "Saved successfully", + "created": "Created successfully", + "updated": "Updated successfully", + "deleted": "Deleted successfully", + "sent": "Sent successfully", + "imported": "Imported successfully", + "exported": "Exported successfully", + "logged_in": "Logged in successfully", + "logged_out": "Logged out successfully" + }, + "errors": { + "required_field": "This field is required", + "invalid_email": "Invalid email address", + "invalid_phone": "Invalid phone number", + "weak_password": "Password must be stronger", + "passwords_not_match": "Passwords do not match", + "network_error": "Network error", + "server_error": "Server error", + "unauthorized": "Unauthorized", + "forbidden": "Access denied", + "not_found": "Not found", + "validation_error": "Validation error", + "file_too_large": "File too large", + "invalid_file_type": "Invalid file type" } } \ No newline at end of file diff --git a/frontend/src/locales/es/auth.json b/frontend/src/locales/es/auth.json index 57eadba5..ad95bf9b 100644 --- a/frontend/src/locales/es/auth.json +++ b/frontend/src/locales/es/auth.json @@ -35,7 +35,9 @@ "passwords_dont_match": "Las contraseñas no coinciden", "accept_terms": "Acepto los términos y condiciones", "accept_privacy": "Acepto la política de privacidad", + "accept_terms_and_privacy": "Acepto los términos y condiciones y la política de privacidad", "marketing_consent": "Quiero recibir newsletters y novedades (opcional)", + "analytics_consent": "Acepto el uso de cookies analíticas para mejorar la experiencia", "register_button": "Crear cuenta", "registering": "Creando cuenta...", "have_account": "¿Ya tienes cuenta?", @@ -44,7 +46,44 @@ "privacy_link": "Política de Privacidad", "step_of": "Paso {current} de {total}", "continue": "Continuar", - "back": "Atrás" + "back": "Atrás", + "next_button": "Siguiente", + "previous_button": "Anterior", + "have_account": "¿Ya tienes una cuenta?", + "sign_in_link": "Iniciar sesión" + }, + "steps": { + "info": "Información", + "subscription": "Plan", + "payment": "Pago" + }, + "payment": { + "payment_summary": "Resumen de tu Plan", + "selected_plan": "Plan seleccionado:", + "monthly_price": "Precio mensual:", + "trial_period": "Período de prueba:", + "total_today": "Total hoy:", + "payment_required": "Tarjeta requerida para validación", + "billing_message": "Se te cobrará {{price}} después del período de prueba", + "free_months": "{{count}} meses GRATIS", + "free_days": "14 días gratis", + "payment_info": "Información de Pago", + "secure_payment": "Tu información de pago está protegida con encriptación de extremo a extremo", + "dev_mode": "Modo Desarrollo", + "cardholder_name": "Nombre del titular", + "email": "Correo electrónico", + "address_line1": "Dirección", + "city": "Ciudad", + "state": "Estado/Provincia", + "postal_code": "Código Postal", + "country": "País", + "card_details": "Detalles de la tarjeta", + "card_info_secure": "Tu información de tarjeta está segura" + }, + "alerts": { + "success_create": "Cuenta creada exitosamente", + "error_create": "Error al crear la cuenta", + "payment_error": "Error en el pago" }, "forgot_password": { "title": "Recuperar contraseña", @@ -131,7 +170,7 @@ "last_name_required": "El apellido es requerido", "phone_required": "El teléfono es requerido", "company_name_required": "El nombre de la empresa es requerido", - "terms_required": "Debes aceptar los términos y condiciones", + "terms_required": "Debes aceptar los términos y condiciones y la política de privacidad", "field_required": "Este campo es requerido", "invalid_format": "Formato inválido" }, diff --git a/frontend/src/locales/es/common.json b/frontend/src/locales/es/common.json index 11c5f24d..f00ca96b 100644 --- a/frontend/src/locales/es/common.json +++ b/frontend/src/locales/es/common.json @@ -463,5 +463,31 @@ "features": "Funcionalidades", "about": "Nosotros", "contact": "Contacto" + }, + "messages": { + "saved": "Guardado correctamente", + "created": "Creado correctamente", + "updated": "Actualizado correctamente", + "deleted": "Eliminado correctamente", + "sent": "Enviado correctamente", + "imported": "Importado correctamente", + "exported": "Exportado correctamente", + "logged_in": "Sesión iniciada correctamente", + "logged_out": "Sesión cerrada correctamente" + }, + "errors": { + "required_field": "Este campo es obligatorio", + "invalid_email": "Email no válido", + "invalid_phone": "Teléfono no válido", + "weak_password": "La contraseña debe ser más segura", + "passwords_not_match": "Las contraseñas no coinciden", + "network_error": "Error de red", + "server_error": "Error del servidor", + "unauthorized": "No autorizado", + "forbidden": "Acceso denegado", + "not_found": "No encontrado", + "validation_error": "Error de validación", + "file_too_large": "Archivo demasiado grande", + "invalid_file_type": "Tipo de archivo no válido" } } \ No newline at end of file diff --git a/frontend/src/locales/eu/auth.json b/frontend/src/locales/eu/auth.json index ef0804fb..1b050356 100644 --- a/frontend/src/locales/eu/auth.json +++ b/frontend/src/locales/eu/auth.json @@ -35,7 +35,9 @@ "passwords_dont_match": "Pasahitzak ez datoz bat", "accept_terms": "Baldintza eta baldintzak onartzen ditut", "accept_privacy": "Pribatutasun politika onartzen dut", + "accept_terms_and_privacy": "Baldintzak eta pribatutasun politika onartzen ditut", "marketing_consent": "Newsletter eta berriak jaso nahi ditut (aukerakoa)", + "analytics_consent": "Ados nago analitikarako cookieak erabiltzearekin esperientzia hobetzeko", "register_button": "Sortu kontua", "registering": "Kontua sortzen...", "have_account": "Dagoeneko baduzu kontua?", @@ -44,7 +46,75 @@ "privacy_link": "Pribatutasun politika", "step_of": "{current}. urratsa {total}-tik", "continue": "Jarraitu", - "back": "Atzera" + "back": "Atzera", + "next_button": "Hurrengoa", + "previous_button": "Aurrekoa" + }, + "steps": { + "info": "Informazioa", + "subscription": "Harpidetza", + "payment": "Ordainketa" + }, + "payment": { + "payment_summary": "Zure planaren laburpena", + "selected_plan": "Hautatutako plana:", + "monthly_price": "Hileroko prezioa:", + "trial_period": "Proba epea:", + "total_today": "Gaurko totala:", + "payment_required": "Ordainketa beharrezkoa balidaziorako", + "billing_message": "{{price}} kobratuko zaizu proba epea ondoren", + "free_months": "{{count}} hilabete DOAN", + "free_days": "14 egun doan", + "payment_info": "Ordainketaren informazioa", + "secure_payment": "Zure ordainketa informazioa babespetuta dago amaieratik amaierarako zifratzearekin", + "dev_mode": "Garapen Modua", + "cardholder_name": "Txartelaren titularra", + "email": "Helbide elektronikoa", + "address_line1": "Helbidea", + "city": "Hiria", + "state": "Estatua/Probintzia", + "postal_code": "Posta kodea", + "country": "Herrialdea", + "card_details": "Txartelaren xehetasunak", + "card_info_secure": "Zure txartelaren informazioa segurua da", + "process_payment": "Prozesatu Ordainketa", + "payment_bypassed_title": "Ordainketa Saltatua", + "payment_bypassed_description": "Ordainketa prozesua saltatu da garapen moduan. Erregistratzea modu normalean jarraituko du.", + "continue_registration": "Erregistratzearekin Jarraitu", + "payment_bypassed": "Ordainketa Saltatua", + "bypass_payment": "Saltatu Ordainketa" + }, + "subscription": { + "select_plan": "Hautatu zure plana", + "choose_plan": "Aukeratu zure negozioari egokiena den plana" + }, + "payment": { + "payment_summary": "Zure planaren laburpena", + "selected_plan": "Hautatutako plana:", + "monthly_price": "Hileroko prezioa:", + "trial_period": "Proba epea:", + "total_today": "Gaurko totala:", + "payment_required": "Ordainketa beharrezkoa balidaziorako", + "billing_message": "{price} kobratuko zaizu proba epea ondoren", + "free_months": "{count} hilabete DOAN", + "free_days": "14 egun doan", + "payment_info": "Ordainketa informazioa", + "secure_payment": "Zure ordainketa informazioa babespetuta dago amaieratik amaierarako zifratzearekin", + "dev_mode": "Garapen Modua", + "cardholder_name": "Txartelaren titularra", + "email": "Helbide elektronikoa", + "address_line1": "Helbidea", + "city": "Hiria", + "state": "Estatua/Probintzia", + "postal_code": "Posta kodea", + "country": "Herrialdea", + "card_details": "Txartelaren xehetasunak", + "card_info_secure": "Zure txartelaren informazioa segurua da" + }, + "alerts": { + "success_create": "Kontua behar bezala sortu da", + "error_create": "Errorea kontua sortzean", + "payment_error": "Ordainketa errorea" }, "forgot_password": { "title": "Berrezarri pasahitza", @@ -124,14 +194,14 @@ "email_required": "Helbide elektronikoa beharrezkoa da", "email_invalid": "Mesedez, sartu baliozko helbide elektroniko bat", "password_required": "Pasahitza beharrezkoa da", - "password_min_length": "Pasahitzak gutxienez {{min}} karaktere izan behar ditu", + "password_min_length": "Pasahitzak gutxienez {min} karaktere izan behar ditu", "password_weak": "Pasahitza ahulegia da", "passwords_must_match": "Pasahitzak bat etorri behar dira", "first_name_required": "Izena beharrezkoa da", "last_name_required": "Abizena beharrezkoa da", "phone_required": "Telefonoa beharrezkoa da", "company_name_required": "Enpresaren izena beharrezkoa da", - "terms_required": "Baldintza eta baldintzak onartu behar dituzu", + "terms_required": "Baldintzak eta pribatutasun politika onartu behar dituzu", "field_required": "Eremu hau beharrezkoa da", "invalid_format": "Formatu baliogabea" }, @@ -162,5 +232,10 @@ "manage_sales": "Kudeatu salmentak", "view_reports": "Ikusi txostenak", "manage_settings": "Kudeatu ezarpenak" + }, + "alerts": { + "success_create": "Kontua behar bezala sortu da", + "error_create": "Errorea kontua sortzean", + "payment_error": "Ordainketa errorea" } } \ No newline at end of file diff --git a/frontend/src/locales/eu/common.json b/frontend/src/locales/eu/common.json index a09142d2..99416337 100644 --- a/frontend/src/locales/eu/common.json +++ b/frontend/src/locales/eu/common.json @@ -437,5 +437,31 @@ "features": "Ezaugarriak", "about": "Guri buruz", "contact": "Harremanetan jarri" + }, + "messages": { + "saved": "Ondo gordeta", + "created": "Ondo sortuta", + "updated": "Ondo eguneratuta", + "deleted": "Ondo ezabatuta", + "sent": "Ondo bidalia", + "imported": "Ondo inportatuta", + "exported": "Ondo esportatuta", + "logged_in": "Saioa ondo hasita", + "logged_out": "Saioa ondo itxita" + }, + "errors": { + "required_field": "Eremu hau beharrezkoa da", + "invalid_email": "Email baliogabea", + "invalid_phone": "Telefono baliogabea", + "weak_password": "Pasahitza indartsuagoa izan behar da", + "passwords_not_match": "Pasahitzak ez datoz bat", + "network_error": "Sare-errorea", + "server_error": "Zerbitzari-errorea", + "unauthorized": "Baimenik gabe", + "forbidden": "Sarbidea ukatuta", + "not_found": "Ez da aurkitu", + "validation_error": "Balidazio-errorea", + "file_too_large": "Fitxategia handiegia da", + "invalid_file_type": "Fitxategi mota baliogabea" } } diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index ab307d73..72273915 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -339,30 +339,30 @@ export const ERROR_CODES = { // Success messages export const SUCCESS_MESSAGES = { - SAVED: 'Guardado correctamente', - CREATED: 'Creado correctamente', - UPDATED: 'Actualizado correctamente', - DELETED: 'Eliminado correctamente', - SENT: 'Enviado correctamente', - IMPORTED: 'Importado correctamente', - EXPORTED: 'Exportado correctamente', - LOGGED_IN: 'Sesión iniciada', - LOGGED_OUT: 'Sesión cerrada', + SAVED: 'common:messages.saved', + CREATED: 'common:messages.created', + UPDATED: 'common:messages.updated', + DELETED: 'common:messages.deleted', + SENT: 'common:messages.sent', + IMPORTED: 'common:messages.imported', + EXPORTED: 'common:messages.exported', + LOGGED_IN: 'common:messages.logged_in', + LOGGED_OUT: 'common:messages.logged_out', } as const; // Error messages export const ERROR_MESSAGES = { - REQUIRED_FIELD: 'Este campo es obligatorio', - INVALID_EMAIL: 'Email no válido', - INVALID_PHONE: 'Teléfono no válido', - WEAK_PASSWORD: 'La contraseña debe ser más segura', - PASSWORDS_NOT_MATCH: 'Las contraseñas no coinciden', - NETWORK_ERROR: 'Error de conexión', - SERVER_ERROR: 'Error del servidor', - UNAUTHORIZED: 'No autorizado', - FORBIDDEN: 'Acceso denegado', - NOT_FOUND: 'No encontrado', - VALIDATION_ERROR: 'Error de validación', - FILE_TOO_LARGE: 'Archivo demasiado grande', - INVALID_FILE_TYPE: 'Tipo de archivo no válido', + REQUIRED_FIELD: 'common:errors.required_field', + INVALID_EMAIL: 'common:errors.invalid_email', + INVALID_PHONE: 'common:errors.invalid_phone', + WEAK_PASSWORD: 'common:errors.weak_password', + PASSWORDS_NOT_MATCH: 'common:errors.passwords_not_match', + NETWORK_ERROR: 'common:errors.network_error', + SERVER_ERROR: 'common:errors.server_error', + UNAUTHORIZED: 'common:errors.unauthorized', + FORBIDDEN: 'common:errors.forbidden', + NOT_FOUND: 'common:errors.not_found', + VALIDATION_ERROR: 'common:errors.validation_error', + FILE_TOO_LARGE: 'common:errors.file_too_large', + INVALID_FILE_TYPE: 'common:errors.invalid_file_type', } as const; \ No newline at end of file diff --git a/frontend/src/utils/translationHelpers.ts b/frontend/src/utils/translationHelpers.ts new file mode 100644 index 00000000..c0e5f037 --- /dev/null +++ b/frontend/src/utils/translationHelpers.ts @@ -0,0 +1,32 @@ +import { useTranslation } from 'react-i18next'; +import { SUCCESS_MESSAGES, ERROR_MESSAGES } from '../utils/constants'; + +/** + * Hook to get translated success messages + */ +export const useSuccessMessages = () => { + const { t } = useTranslation(); + + const getMessage = (key: keyof typeof SUCCESS_MESSAGES): string => { + const translationKey = SUCCESS_MESSAGES[key]; + const [namespace, path] = translationKey.split(':'); + return t(`${namespace}:${path}`); + }; + + return { getMessage }; +}; + +/** + * Hook to get translated error messages + */ +export const useErrorMessages = () => { + const { t } = useTranslation(); + + const getMessage = (key: keyof typeof ERROR_MESSAGES): string => { + const translationKey = ERROR_MESSAGES[key]; + const [namespace, path] = translationKey.split(':'); + return t(`${namespace}:${path}`); + }; + + return { getMessage }; +}; \ No newline at end of file diff --git a/gateway/app/routes/tenant.py b/gateway/app/routes/tenant.py index 90110c7e..0c5673f1 100644 --- a/gateway/app/routes/tenant.py +++ b/gateway/app/routes/tenant.py @@ -49,10 +49,10 @@ async def get_tenant_children(request: Request, tenant_id: str = Path(...)): return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/children") -@router.api_route("/bulk-children", methods=["POST", "OPTIONS"]) -async def proxy_bulk_children(request: Request): +@router.api_route("/{tenant_id}/bulk-children", methods=["POST", "OPTIONS"]) +async def proxy_bulk_children(request: Request, tenant_id: str = Path(...)): """Proxy bulk children creation requests to tenant service""" - return await _proxy_to_tenant_service(request, "/api/v1/tenants/bulk-children") + return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/bulk-children") @router.api_route("/{tenant_id}/children/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) async def proxy_tenant_children(request: Request, tenant_id: str = Path(...), path: str = ""): diff --git a/services/tenant/app/api/tenant_hierarchy.py b/services/tenant/app/api/tenant_hierarchy.py index a8f483e0..c0c749c6 100644 --- a/services/tenant/app/api/tenant_hierarchy.py +++ b/services/tenant/app/api/tenant_hierarchy.py @@ -219,10 +219,11 @@ async def get_tenant_children_count( ) -@router.post(route_builder.build_base_route("bulk-children", include_tenant_prefix=False), response_model=BulkChildTenantsResponse) +@router.post("/api/v1/tenants/{tenant_id}/bulk-children", response_model=BulkChildTenantsResponse) @track_endpoint_metrics("bulk_create_child_tenants") async def bulk_create_child_tenants( request: BulkChildTenantsCreate, + tenant_id: str = Path(..., description="Parent tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): @@ -242,7 +243,7 @@ async def bulk_create_child_tenants( try: logger.info( "Bulk child tenant creation request received", - parent_tenant_id=request.parent_tenant_id, + parent_tenant_id=tenant_id, child_count=len(request.child_tenants), user_id=current_user.get("user_id") ) @@ -252,7 +253,7 @@ async def bulk_create_child_tenants( from app.models.tenants import Tenant tenant_repo = TenantRepository(Tenant, session) - parent_tenant = await tenant_repo.get_by_id(request.parent_tenant_id) + parent_tenant = await tenant_repo.get_by_id(tenant_id) if not parent_tenant: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -262,7 +263,7 @@ async def bulk_create_child_tenants( # Verify user has access to parent tenant (owners/admins only) access_info = await tenant_service.verify_user_access( current_user["user_id"], - request.parent_tenant_id + tenant_id ) if not access_info.has_access or access_info.role not in ["owner", "admin"]: raise HTTPException( @@ -271,8 +272,8 @@ async def bulk_create_child_tenants( ) # Verify parent is enterprise tier - parent_subscription_tier = await tenant_service.get_subscription_tier(request.parent_tenant_id) - if parent_subscription_tier != "enterprise": + parent_subscription = await tenant_service.subscription_repo.get_active_subscription(tenant_id) + if not parent_subscription or parent_subscription.plan != "enterprise": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Only enterprise tier tenants can have child tenants" @@ -290,121 +291,136 @@ async def bulk_create_child_tenants( failed_tenants = [] for child_data in request.child_tenants: - try: - # Create child tenant - child_tenant = Tenant( - name=child_data.name, - subdomain=None, # Child tenants typically don't have subdomains - business_type=parent_tenant.business_type, - business_model="retail_bakery", # Child outlets are typically retail - address=child_data.address, - city=child_data.city, - postal_code=child_data.postal_code, - latitude=child_data.latitude, - longitude=child_data.longitude, - phone=child_data.phone or parent_tenant.phone, - email=child_data.email or parent_tenant.email, - timezone=parent_tenant.timezone, - owner_id=parent_tenant.owner_id, - parent_tenant_id=parent_tenant.id, - tenant_type="child", - hierarchy_path=f"{parent_tenant.hierarchy_path}/{str(parent_tenant.id)}", - is_active=True, - is_demo=parent_tenant.is_demo, - demo_session_id=parent_tenant.demo_session_id, - demo_expires_at=parent_tenant.demo_expires_at - ) - - session.add(child_tenant) - await session.flush() # Get the ID without committing - - # Create TenantLocation record for the child with location_code - from app.models.tenant_location import TenantLocation - location = TenantLocation( - tenant_id=child_tenant.id, - name=child_data.name, - location_code=child_data.location_code, - city=child_data.city, - zone=child_data.zone, - address=child_data.address, - postal_code=child_data.postal_code, - latitude=child_data.latitude, - longitude=child_data.longitude, - status="ACTIVE", - is_primary=True, - enterprise_location=True, - location_type="retail" - ) - session.add(location) - - # Inherit subscription from parent - from app.models.tenants import Subscription - parent_subscription = await session.execute( - session.query(Subscription).filter( - Subscription.tenant_id == parent_tenant.id, - Subscription.status == "active" - ).statement - ) - parent_sub = parent_subscription.scalar_one_or_none() - - if parent_sub: - child_subscription = Subscription( - tenant_id=child_tenant.id, - plan=parent_sub.plan, - status="active", - billing_cycle=parent_sub.billing_cycle, - price=0, # Child tenants don't pay separately - trial_ends_at=parent_sub.trial_ends_at + # Create a nested transaction (savepoint) for each child tenant + # This allows us to rollback individual child tenant creation without affecting others + async with session.begin_nested(): + try: + # Create child tenant with full tenant model fields + child_tenant = Tenant( + name=child_data.name, + subdomain=None, # Child tenants typically don't have subdomains + business_type=child_data.business_type or parent_tenant.business_type, + business_model=child_data.business_model or "retail_bakery", # Child outlets are typically retail + address=child_data.address, + city=child_data.city, + postal_code=child_data.postal_code, + latitude=child_data.latitude, + longitude=child_data.longitude, + phone=child_data.phone or parent_tenant.phone, + email=child_data.email or parent_tenant.email, + timezone=child_data.timezone or parent_tenant.timezone, + owner_id=parent_tenant.owner_id, + parent_tenant_id=parent_tenant.id, + tenant_type="child", + hierarchy_path=f"{parent_tenant.hierarchy_path}", # Will be updated after flush + is_active=True, + is_demo=parent_tenant.is_demo, + demo_session_id=parent_tenant.demo_session_id, + demo_expires_at=parent_tenant.demo_expires_at, + metadata_={ + "location_code": child_data.location_code, + "zone": child_data.zone, + **(child_data.metadata or {}) + } ) - session.add(child_subscription) - await session.commit() - await session.refresh(child_tenant) - await session.refresh(location) + session.add(child_tenant) + await session.flush() # Get the ID without committing - # Build response - created_tenants.append(ChildTenantResponse( - id=str(child_tenant.id), - name=child_tenant.name, - subdomain=child_tenant.subdomain, - business_type=child_tenant.business_type, - business_model=child_tenant.business_model, - tenant_type=child_tenant.tenant_type, - parent_tenant_id=str(child_tenant.parent_tenant_id), - address=child_tenant.address, - city=child_tenant.city, - postal_code=child_tenant.postal_code, - phone=child_tenant.phone, - is_active=child_tenant.is_active, - subscription_plan="enterprise", - ml_model_trained=child_tenant.ml_model_trained, - last_training_date=child_tenant.last_training_date, - owner_id=str(child_tenant.owner_id), - created_at=child_tenant.created_at, - location_code=location.location_code, - zone=location.zone, - hierarchy_path=child_tenant.hierarchy_path - )) + # Update hierarchy_path now that we have the child tenant ID + child_tenant.hierarchy_path = f"{parent_tenant.hierarchy_path}.{str(child_tenant.id)}" - logger.info( - "Child tenant created successfully", - child_tenant_id=str(child_tenant.id), - child_name=child_tenant.name, - location_code=child_data.location_code - ) + # Create TenantLocation record for the child + from app.models.tenant_location import TenantLocation + location = TenantLocation( + tenant_id=child_tenant.id, + name=child_data.name, + city=child_data.city, + address=child_data.address, + postal_code=child_data.postal_code, + latitude=child_data.latitude, + longitude=child_data.longitude, + is_active=True, + location_type="retail" + ) + session.add(location) - except Exception as child_error: - logger.error( - "Failed to create child tenant", - child_name=child_data.name, - error=str(child_error) - ) - failed_tenants.append({ - "name": child_data.name, - "location_code": child_data.location_code, - "error": str(child_error) - }) - await session.rollback() + # Inherit subscription from parent + from app.models.tenants import Subscription + from sqlalchemy import select + parent_subscription_result = await session.execute( + select(Subscription).where( + Subscription.tenant_id == parent_tenant.id, + Subscription.status == "active" + ) + ) + parent_sub = parent_subscription_result.scalar_one_or_none() + + if parent_sub: + child_subscription = Subscription( + tenant_id=child_tenant.id, + plan=parent_sub.plan, + status="active", + billing_cycle=parent_sub.billing_cycle, + monthly_price=0, # Child tenants don't pay separately + trial_ends_at=parent_sub.trial_ends_at + ) + session.add(child_subscription) + + # Commit the nested transaction (savepoint) + await session.flush() + + # Refresh objects to get their final state + await session.refresh(child_tenant) + await session.refresh(location) + + # Build response + created_tenants.append(ChildTenantResponse( + id=str(child_tenant.id), + name=child_tenant.name, + subdomain=child_tenant.subdomain, + business_type=child_tenant.business_type, + business_model=child_tenant.business_model, + tenant_type=child_tenant.tenant_type, + parent_tenant_id=str(child_tenant.parent_tenant_id), + address=child_tenant.address, + city=child_tenant.city, + postal_code=child_tenant.postal_code, + phone=child_tenant.phone, + is_active=child_tenant.is_active, + subscription_plan="enterprise", + ml_model_trained=child_tenant.ml_model_trained, + last_training_date=child_tenant.last_training_date, + owner_id=str(child_tenant.owner_id), + created_at=child_tenant.created_at, + location_code=child_data.location_code, + zone=child_data.zone, + hierarchy_path=child_tenant.hierarchy_path + )) + + logger.info( + "Child tenant created successfully", + child_tenant_id=str(child_tenant.id), + child_name=child_tenant.name, + location_code=child_data.location_code + ) + + except Exception as child_error: + logger.error( + "Failed to create child tenant", + child_name=child_data.name, + error=str(child_error) + ) + failed_tenants.append({ + "name": child_data.name, + "location_code": child_data.location_code, + "error": str(child_error) + }) + # Nested transaction will automatically rollback on exception + # This only rolls back the current child tenant, not the entire batch + + # Commit all successful child tenant creations + await session.commit() # TODO: Configure distribution routes if requested distribution_configured = False @@ -414,7 +430,7 @@ async def bulk_create_child_tenants( # For now, we'll skip this and just log logger.info( "Distribution route configuration requested", - parent_tenant_id=request.parent_tenant_id, + parent_tenant_id=tenant_id, child_count=len(created_tenants) ) # distribution_configured = await configure_distribution_routes(...) @@ -426,13 +442,13 @@ async def bulk_create_child_tenants( logger.info( "Bulk child tenant creation completed", - parent_tenant_id=request.parent_tenant_id, + parent_tenant_id=tenant_id, created_count=len(created_tenants), failed_count=len(failed_tenants) ) return BulkChildTenantsResponse( - parent_tenant_id=request.parent_tenant_id, + parent_tenant_id=tenant_id, created_count=len(created_tenants), failed_count=len(failed_tenants), created_tenants=created_tenants, @@ -445,7 +461,7 @@ async def bulk_create_child_tenants( except Exception as e: logger.error( "Bulk child tenant creation failed", - parent_tenant_id=request.parent_tenant_id, + parent_tenant_id=tenant_id, user_id=current_user.get("user_id"), error=str(e) ) diff --git a/services/tenant/app/schemas/tenants.py b/services/tenant/app/schemas/tenants.py index 3463e207..659927a5 100644 --- a/services/tenant/app/schemas/tenants.py +++ b/services/tenant/app/schemas/tenants.py @@ -204,7 +204,7 @@ class TenantStatsResponse(BaseModel): # ============================================================================ class ChildTenantCreate(BaseModel): - """Schema for creating a child tenant in enterprise hierarchy""" + """Schema for creating a child tenant in enterprise hierarchy - Updated to match tenant model""" name: str = Field(..., min_length=2, max_length=200, description="Child tenant name (e.g., 'Madrid - Salamanca')") city: str = Field(..., min_length=2, max_length=100, description="City where the outlet is located") zone: Optional[str] = Field(None, max_length=100, description="Zone or neighborhood") @@ -212,14 +212,24 @@ class ChildTenantCreate(BaseModel): postal_code: str = Field(..., pattern=r"^\d{5}$", description="5-digit postal code") location_code: str = Field(..., min_length=1, max_length=10, description="Short location code (e.g., MAD, BCN)") - # Optional coordinates (can be geocoded from address if not provided) + # Coordinates (can be geocoded from address if not provided) latitude: Optional[float] = Field(None, ge=-90, le=90, description="Latitude coordinate") longitude: Optional[float] = Field(None, ge=-180, le=180, description="Longitude coordinate") - # Optional contact info (inherits from parent if not provided) + # Contact info (inherits from parent if not provided) phone: Optional[str] = Field(None, min_length=9, max_length=20, description="Contact phone") email: Optional[str] = Field(None, description="Contact email") + # Business info + business_type: Optional[str] = Field(None, max_length=100, description="Type of business") + business_model: Optional[str] = Field(None, max_length=100, description="Business model") + + # Timezone configuration + timezone: Optional[str] = Field(None, max_length=50, description="Timezone for scheduling") + + # Additional metadata + metadata: Optional[Dict[str, Any]] = Field(None, description="Additional metadata for the child tenant") + @field_validator('location_code') @classmethod def validate_location_code(cls, v): @@ -243,10 +253,42 @@ class ChildTenantCreate(BaseModel): raise ValueError('Invalid Spanish phone number') return v + @field_validator('business_type') + @classmethod + def validate_business_type(cls, v): + """Validate business type if provided""" + if v is None: + return v + valid_types = ['bakery', 'coffee_shop', 'pastry_shop', 'restaurant'] + if v not in valid_types: + raise ValueError(f'Business type must be one of: {valid_types}') + return v + + @field_validator('business_model') + @classmethod + def validate_business_model(cls, v): + """Validate business model if provided""" + if v is None: + return v + valid_models = ['individual_bakery', 'central_baker_satellite', 'retail_bakery', 'hybrid_bakery'] + if v not in valid_models: + raise ValueError(f'Business model must be one of: {valid_models}') + return v + + @field_validator('timezone') + @classmethod + def validate_timezone(cls, v): + """Validate timezone if provided""" + if v is None: + return v + # Basic timezone validation - should match common timezone formats + if not re.match(r'^[A-Za-z_+/]+$', v): + raise ValueError('Invalid timezone format') + return v + class BulkChildTenantsCreate(BaseModel): """Schema for bulk creating child tenants during onboarding""" - parent_tenant_id: str = Field(..., description="ID of the parent (central baker) tenant") child_tenants: List[ChildTenantCreate] = Field( ..., min_length=1,