diff --git a/src/front/i18n/messages.mjs b/src/front/i18n/messages.mjs
index 19c86ea..b71352f 100644
--- a/src/front/i18n/messages.mjs
+++ b/src/front/i18n/messages.mjs
@@ -96,6 +96,23 @@ export const interfaceMessages = {
activatingPlan: message("Activating plan...", "Activando plan...", "Ativando plano..."),
planActivationError: message("Unable to activate the plan.", "No se pudo activar el plan.", "Não foi possível ativar o plano."),
planAdminRequired: message("Ask a company owner or administrator to activate a plan.", "Solicita al propietario o a un administrador de la empresa que active un plan.", "Peça ao proprietário ou a um administrador da empresa para ativar um plano."),
+ continueToPayment: message("Continue to payment", "Continuar al pago", "Continuar para o pagamento"),
+ paymentTitle: message("Complete your subscription", "Completa tu suscripción", "Conclua sua assinatura"),
+ paymentSubtitle: message("Review your plan and confirm the simulated payment.", "Revisa tu plan y confirma el pago simulado.", "Revise seu plano e confirme o pagamento simulado."),
+ orderSummary: message("Order summary", "Resumen del pedido", "Resumo do pedido"),
+ selectedPlan: message("Selected plan", "Plan seleccionado", "Plano selecionado"),
+ billedMonthly: message("Billed monthly", "Facturación mensual", "Cobrança mensal"),
+ paymentDetails: message("Payment details", "Datos de pago", "Dados de pagamento"),
+ cardholderName: message("Cardholder name", "Nombre del titular", "Nome do titular"),
+ cardNumber: message("Card number", "Número de tarjeta", "Número do cartão"),
+ expiryDate: message("Expiry date", "Fecha de caducidad", "Validade"),
+ securityCode: message("Security code", "Código de seguridad", "Código de segurança"),
+ simulatedPaymentNotice: message("Academic simulation: use test values only. No real charge will be made and card data will not be sent or stored.", "Simulación académica: usa únicamente datos de prueba. No se realizará ningún cargo real y los datos de la tarjeta no se enviarán ni almacenarán.", "Simulação acadêmica: use somente dados de teste. Nenhuma cobrança real será feita e os dados do cartão não serão enviados nem armazenados."),
+ invalidPaymentDetails: message("Complete the simulated payment details correctly.", "Completa correctamente los datos del pago simulado.", "Preencha corretamente os dados do pagamento simulado."),
+ confirmPayment: message("Confirm simulated payment", "Confirmar pago simulado", "Confirmar pagamento simulado"),
+ confirmingPayment: message("Confirming payment...", "Confirmando pago...", "Confirmando pagamento..."),
+ changePlan: message("Change plan", "Cambiar plan", "Alterar plano"),
+ planNotFound: message("The selected plan is not available.", "El plan seleccionado no está disponible.", "O plano selecionado não está disponível."),
continue: message("Continue", "Continuar", "Continuar"),
invitationTitle: message("Join your team", "Únete a tu equipo", "Entre para sua equipe"),
diff --git a/src/front/pages/Layout.jsx b/src/front/pages/Layout.jsx
index 5fda4f0..0ab995b 100644
--- a/src/front/pages/Layout.jsx
+++ b/src/front/pages/Layout.jsx
@@ -13,7 +13,7 @@ export const Layout = () => {
const [sidebarOpen, setSidebarOpen] = useState(false);
// Rutas públicas donde NO queremos que aparezca el Sidebar ni el Layout envolvente
- const hideSidebarPaths = ["/accept-invitation", "/login", "/register", "/forgot-password", "/reset-password", "/select-plan"];
+ const hideSidebarPaths = ["/accept-invitation", "/login", "/register", "/forgot-password", "/reset-password", "/select-plan", "/payment"];
const shouldHideSidebar = hideSidebarPaths.includes(location.pathname);
// SI ES UNA RUTA PÚBLICA: Devolvemos únicamente la vista sin contenedores extra de Layout
diff --git a/src/front/pages/Payment.jsx b/src/front/pages/Payment.jsx
new file mode 100644
index 0000000..8b384a7
--- /dev/null
+++ b/src/front/pages/Payment.jsx
@@ -0,0 +1,207 @@
+import { useEffect, useMemo, useState } from "react";
+import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
+import { AuthLayout } from "../components/AuthLayout";
+import { useLanguage } from "../context/LanguageContext";
+import { subscriptionService } from "../services/subscriptionService.mjs";
+
+const normalizeCardNumber = (value) => value.replace(/\D/g, "").slice(0, 16);
+const formatCardNumber = (value) => normalizeCardNumber(value).replace(/(.{4})/g, "$1 ").trim();
+
+export const Payment = () => {
+ const { ui } = useLanguage();
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const token = localStorage.getItem("access_token");
+ const planId = Number(searchParams.get("plan_id"));
+ const [plan, setPlan] = useState(null);
+ const [companyId, setCompanyId] = useState(null);
+ const [companyRole, setCompanyRole] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState("");
+ const [form, setForm] = useState({
+ cardholder: "",
+ cardNumber: "",
+ expiry: "",
+ securityCode: "",
+ });
+
+ const validPlanId = Number.isSafeInteger(planId) && planId > 0;
+ const canActivate = ["owner", "admin"].includes(companyRole);
+ const cardDigits = useMemo(() => normalizeCardNumber(form.cardNumber), [form.cardNumber]);
+
+ useEffect(() => {
+ if (!token || !validPlanId) {
+ setLoading(false);
+ return undefined;
+ }
+
+ const controller = new AbortController();
+ const apiUrl = (import.meta.env.VITE_BACKEND_URL || "").replace(/\/$/, "");
+
+ Promise.all([
+ fetch(`${apiUrl}/api/me`, {
+ headers: { Authorization: `Bearer ${token}` },
+ signal: controller.signal,
+ }),
+ fetch(`${apiUrl}/api/plans`, { signal: controller.signal }),
+ ])
+ .then(async ([accountResponse, plansResponse]) => {
+ if (accountResponse.status === 401) {
+ localStorage.removeItem("access_token");
+ navigate("/login", { replace: true });
+ return;
+ }
+ if (!accountResponse.ok) throw new Error(ui.accountLoadError);
+ if (!plansResponse.ok) throw new Error(ui.plansError);
+
+ const [account, plans] = await Promise.all([
+ accountResponse.json(),
+ plansResponse.json(),
+ ]);
+ const company = account.companies?.[0];
+ if (!company) throw new Error(ui.noCompany);
+ const selectedPlan = Array.isArray(plans)
+ ? plans.find((candidate) => candidate.id === planId)
+ : null;
+ if (!selectedPlan) throw new Error(ui.planNotFound);
+
+ setCompanyId(company.id);
+ setCompanyRole(company.role);
+ setPlan(selectedPlan);
+ })
+ .catch((requestError) => {
+ if (!controller.signal.aborted) {
+ setError(requestError.message || ui.connectionError);
+ }
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false);
+ });
+
+ return () => controller.abort();
+ }, [navigate, planId, token, ui.accountLoadError, ui.connectionError, ui.noCompany, ui.planNotFound, ui.plansError, validPlanId]);
+
+ const updateField = (event) => {
+ const { name, value } = event.target;
+ let nextValue = value;
+ if (name === "cardNumber") nextValue = formatCardNumber(value);
+ if (name === "securityCode") nextValue = value.replace(/\D/g, "").slice(0, 4);
+ if (name === "expiry") {
+ const digits = value.replace(/\D/g, "").slice(0, 4);
+ nextValue = digits.length > 2 ? `${digits.slice(0, 2)}/${digits.slice(2)}` : digits;
+ }
+ setForm((current) => ({ ...current, [name]: nextValue }));
+ };
+
+ const paymentDetailsAreValid = () => {
+ const [month, year] = form.expiry.split("/").map(Number);
+ const today = new Date();
+ const currentMonth = today.getMonth() + 1;
+ const currentYear = today.getFullYear() % 100;
+ const expiryIsCurrentOrFuture =
+ year > currentYear || (year === currentYear && month >= currentMonth);
+ return (
+ form.cardholder.trim().length >= 2 &&
+ cardDigits.length === 16 &&
+ month >= 1 &&
+ month <= 12 &&
+ Number.isInteger(year) &&
+ expiryIsCurrentOrFuture &&
+ form.securityCode.length >= 3
+ );
+ };
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ if (!plan || !companyId || !canActivate || !paymentDetailsAreValid()) {
+ setError(canActivate ? ui.invalidPaymentDetails : ui.planAdminRequired);
+ return;
+ }
+
+ setError("");
+ setSubmitting(true);
+ try {
+ await subscriptionService.activate({
+ token,
+ companyId,
+ planId: plan.id,
+ });
+ navigate("/dashboard", { replace: true });
+ } catch (activationError) {
+ setError(activationError.message || ui.planActivationError);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (!token) return {ui.paymentSubtitle} {ui.loading}{ui.paymentTitle}
+