Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/front/i18n/messages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion src/front/pages/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
207 changes: 207 additions & 0 deletions src/front/pages/Payment.jsx
Original file line number Diff line number Diff line change
@@ -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 <Navigate to="/login" replace />;
if (!validPlanId) return <Navigate to="/select-plan?reason=expired" replace />;

return (
<AuthLayout>
<div className="w-100">
<div className="rounded-3 d-flex align-items-center justify-content-center mb-4 border border-purple-subtle" style={{ width: "3rem", height: "3rem", backgroundColor: "#f3e8ff", color: "#9333ea" }}>
💳
</div>
<h2 className="fw-bold text-body fs-3 mb-1">{ui.paymentTitle}</h2>
<p className="text-muted small mb-4">{ui.paymentSubtitle}</p>

{loading && <p role="status">{ui.loading}</p>}
{error && <div className="alert alert-danger py-2 small" role="alert">{error}</div>}
{!loading && !canActivate && companyRole && (
<div className="alert alert-info py-2 small" role="status">{ui.planAdminRequired}</div>
)}

{plan && (
<form onSubmit={handleSubmit} className="vstack gap-3" autoComplete="off">
<section className="rounded-3 border p-3 bg-body-tertiary">
<p className="text-uppercase fw-bold text-secondary small mb-2">{ui.orderSummary}</p>
<div className="d-flex justify-content-between gap-3">
<div>
<div className="fw-semibold text-body">{plan.name}</div>
<small className="text-muted">{ui.billedMonthly}</small>
</div>
<div className="fw-bold" style={{ color: "#9333ea" }}>
€{plan.price_eur}{ui.perMonth}
</div>
</div>
</section>

<fieldset disabled={submitting || !canActivate} className="vstack gap-3">
<legend className="fs-6 fw-semibold mb-0">{ui.paymentDetails}</legend>
<div>
<label htmlFor="cardholder" className="form-label small fw-semibold">{ui.cardholderName}</label>
<input id="cardholder" name="cardholder" value={form.cardholder} onChange={updateField} autoComplete="off" className="form-control bg-body text-body" required />
</div>
<div>
<label htmlFor="cardNumber" className="form-label small fw-semibold">{ui.cardNumber}</label>
<input id="cardNumber" name="cardNumber" value={form.cardNumber} onChange={updateField} inputMode="numeric" autoComplete="off" placeholder="4242 4242 4242 4242" className="form-control bg-body text-body" required />
</div>
<div className="row g-2">
<div className="col-7">
<label htmlFor="expiry" className="form-label small fw-semibold">{ui.expiryDate}</label>
<input id="expiry" name="expiry" value={form.expiry} onChange={updateField} inputMode="numeric" autoComplete="off" placeholder="MM/YY" className="form-control bg-body text-body" required />
</div>
<div className="col-5">
<label htmlFor="securityCode" className="form-label small fw-semibold">{ui.securityCode}</label>
<input id="securityCode" name="securityCode" value={form.securityCode} onChange={updateField} inputMode="numeric" autoComplete="off" placeholder="123" className="form-control bg-body text-body" required />
</div>
</div>
</fieldset>

<div className="alert alert-info py-2 small mb-0" role="note">{ui.simulatedPaymentNotice}</div>
<div className="d-flex gap-2">
<button type="button" className="btn btn-outline-secondary flex-grow-1" onClick={() => navigate("/select-plan?reason=expired")} disabled={submitting}>
{ui.changePlan}
</button>
<button type="submit" className="btn text-white fw-semibold flex-grow-1" style={{ backgroundColor: "#9333ea", borderColor: "#9333ea" }} disabled={loading || submitting || !canActivate}>
{submitting ? ui.confirmingPayment : ui.confirmPayment}
</button>
</div>
</form>
)}
</div>
</AuthLayout>
);
};
35 changes: 6 additions & 29 deletions src/front/pages/PlanSelection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -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");
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -192,12 +178,6 @@ export const PlanSelection = () => {
</div>
)}

{activationError && (
<div className="alert alert-danger" role="alert">
{activationError}
</div>
)}

{!plansLoading && !plansError && plans.length === 0 && (
<div className="alert alert-info" role="status">
{ui.noPlans}
Expand Down Expand Up @@ -277,7 +257,6 @@ export const PlanSelection = () => {
disabled={
plansLoading ||
accountLoading ||
activating ||
Boolean(plansError) ||
Boolean(accountError) ||
(renewalMode && !canActivate) ||
Expand All @@ -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}
</button>
</form>
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/front/routes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -148,6 +149,7 @@ export const router = createBrowserRouter(
<Route path="login" element={<Login />} />
<Route path="register" element={<Register />} />
<Route path="select-plan" element={<PlanSelection />} />
<Route path="payment" element={<Payment />} />
<Route path="forgot-password" element={<ForgotPassword />} />
<Route path="reset-password" element={<ResetPassword />} />

Expand Down
16 changes: 15 additions & 1 deletion tests/frontend/subscription-expiry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 && <fieldset>/);
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/);
});
Loading