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 ; + if (!validPlanId) return ; + + return ( + +
+
+ 💳 +
+

{ui.paymentTitle}

+

{ui.paymentSubtitle}

+ + {loading &&

{ui.loading}

} + {error &&
{error}
} + {!loading && !canActivate && companyRole && ( +
{ui.planAdminRequired}
+ )} + + {plan && ( +
+
+

{ui.orderSummary}

+
+
+
{plan.name}
+ {ui.billedMonthly} +
+
+ €{plan.price_eur}{ui.perMonth} +
+
+
+ +
+ {ui.paymentDetails} +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
{ui.simulatedPaymentNotice}
+
+ + +
+
+ )} +
+
+ ); +}; diff --git a/src/front/pages/PlanSelection.jsx b/src/front/pages/PlanSelection.jsx index be2190b..ceff09b 100644 --- a/src/front/pages/PlanSelection.jsx +++ b/src/front/pages/PlanSelection.jsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { AuthLayout } from "../components/AuthLayout"; import { useLanguage } from "../context/LanguageContext"; -import { subscriptionService } from "../services/subscriptionService.mjs"; export const PlanSelection = () => { @@ -14,11 +13,9 @@ export const PlanSelection = () => { const [plansLoading, setPlansLoading] = useState(true); const [plansError, setPlansError] = useState(""); const [accountError, setAccountError] = useState(""); - const [activationError, setActivationError] = useState(""); const [companyId, setCompanyId] = useState(null); const [companyRole, setCompanyRole] = useState(null); const [accountLoading, setAccountLoading] = useState(true); - const [activating, setActivating] = useState(false); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const token = localStorage.getItem("access_token"); @@ -120,20 +117,9 @@ export const PlanSelection = () => { if (renewalMode) { if (!canActivate) return; - setActivating(true); - setActivationError(""); - try { - await subscriptionService.activate({ - token, - companyId, - planId: selectedPlanId, - }); - navigate("/dashboard", { replace: true }); - } catch (error) { - setActivationError(error.message || ui.planActivationError); - } finally { - setActivating(false); - } + navigate(`/payment?plan_id=${selectedPlanId}&reason=expired`, { + state: { companyId }, + }); return; } @@ -192,12 +178,6 @@ export const PlanSelection = () => { )} - {activationError && ( -
- {activationError} -
- )} - {!plansLoading && !plansError && plans.length === 0 && (
{ui.noPlans} @@ -277,7 +257,6 @@ export const PlanSelection = () => { disabled={ plansLoading || accountLoading || - activating || Boolean(plansError) || Boolean(accountError) || (renewalMode && !canActivate) || @@ -286,11 +265,9 @@ export const PlanSelection = () => { className="w-100 py-2 btn text-white fw-semibold shadow-sm mt-3" style={{ backgroundColor: "#9333ea", borderColor: "#9333ea" }} > - {activating - ? ui.activatingPlan - : renewalMode - ? ui.activatePlan - : ui.continueRegistration} + {renewalMode + ? ui.continueToPayment + : ui.continueRegistration}
diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 3885085..7d05306 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -15,6 +15,7 @@ import { Layout } from "./pages/Layout"; import { Leads } from "./pages/Leads"; import { Login } from "./pages/Login"; import { Members } from "./pages/Members"; +import { Payment } from "./pages/Payment"; import { PlanSelection } from "./pages/PlanSelection"; import { Register } from "./pages/Register"; import { ResetPassword } from "./pages/ResetPassword"; @@ -148,6 +149,7 @@ export const router = createBrowserRouter( } /> } /> } /> + } /> } /> } /> diff --git a/tests/frontend/subscription-expiry.test.mjs b/tests/frontend/subscription-expiry.test.mjs index 161d410..cdbe6c0 100644 --- a/tests/frontend/subscription-expiry.test.mjs +++ b/tests/frontend/subscription-expiry.test.mjs @@ -43,9 +43,23 @@ test("expired protected routes redirect to plan selection", () => { new URL("../../src/front/pages/PlanSelection.jsx", import.meta.url), "utf8" ); + const payment = readFileSync( + new URL("../../src/front/pages/Payment.jsx", import.meta.url), + "utf8" + ); + const messages = readFileSync( + new URL("../../src/front/i18n/messages.mjs", import.meta.url), + "utf8" + ); assert.match(routes, /data\.code === "subscription_required"/); assert.match(routes, /select-plan\?reason=expired/); + assert.match(routes, /path="payment"/); assert.match(plans, /!renewalMode &&
/); - assert.match(plans, /subscriptionService\.activate/); + assert.match(plans, /navigate\(`\/payment\?plan_id=/); + assert.doesNotMatch(plans, /subscriptionService\.activate/); + assert.match(payment, /subscriptionService\.activate/); + assert.match(payment, /navigate\("\/dashboard"/); + assert.match(payment, /ui\.simulatedPaymentNotice/); + assert.match(messages, /card data will not be sent or stored/); });