/* feedback.jsx
*
* Feedback widget — a small footer rendered on every view that opens a modal
* for users to send suggestions, complaints, bug reports, etc.
*
* The submit path is mailto:-based. We don't run our own SMTP and don't relay
* through a backend; clicking Submit opens the user's mail client with the
* message prefilled and sends as the user. This is intentional:
* - Zero backend, zero API keys to manage, zero spam-handling burden.
* - The user can see and edit what they're about to send before it leaves.
* - Replies from us come from a real human reading a real inbox, which is
* better than a "noreply@" robot anyway.
* Tradeoff: it requires a configured mail client on the user's device, but
* mobile defaults handle this well and desktop Gmail Web works via the
* `https://mail.google.com/mail/?...` fallback some browsers offer.
*
* Two variants exposed:
* — site-wide footer with "Feedback" link
* — chooser-screen button for cert requests
*
* Both open a modal. The cert-request variant routes to a different email
* (requests@cyberstudy.io) and uses a simpler form (no cert/pages context).
*
* Sign-in gating: both variants require an authenticated session. Clicking
* while logged out shows a sign-in prompt with links to login / signup.
*
* Tab-name resolution: the form needs to know which tab the user is on so it
* can prefill "pages this applies to". We read it from window.location.pathname
* since the router writes there. Fallback to 'home'.
*/
(function () {
"use strict";
// Backend endpoints — send via server, not mailto:. Both endpoints require
// an authenticated session; the modal already gates on isLoggedIn so
// unauthenticated users see the SignInPrompt instead.
const FEEDBACK_ENDPOINT = "/api/feedback";
const CERT_REQUEST_ENDPOINT = "/api/cert-request";
// Tab list shown in the multi-select. Order matches the top-bar tabs.
// If new tabs are added to the app, append them here. Kept static (rather
// than derived from app.jsx's tab list) because some tabs aren't user-
// navigable on every cert and we want consistent feedback context labels.
const ALL_TABS = [
{ id: "home", label: "Home" },
{ id: "learn", label: "Learn" },
{ id: "practice", label: "Practice" },
{ id: "progress", label: "Progress" },
{ id: "final-exam", label: "Final Exam" },
{ id: "notes", label: "Notes" },
{ id: "chooser", label: "Cert Chooser" },
{ id: "other", label: "Other / Not Applicable" },
];
const TYPES = [
{ id: "suggestion", label: "Suggestion" },
{ id: "request", label: "Request" },
{ id: "complaint", label: "Complaint" },
{ id: "question", label: "Question" },
{ id: "bug", label: "Bug Report" },
{ id: "other", label: "Other" },
];
// Cert track list is read from window.EXAMS (the single source of truth
// for which certs exist on the site). Adding a new cert to exams.js
// automatically surfaces it in the feedback form dropdown — no edit here
// required. See docs/ADD_NEW_EXAM.md.
function getCerts() {
const exams = window.EXAMS || [];
return exams.map(e => ({ id: e.id, label: e.label }));
}
// ── Helpers ─────────────────────────────────────────────────────────────
function getCurrentTabId() {
const path = (window.location.pathname || "").replace(/^\/+|\/+$/g, "");
if (!path) return "home";
// Known tabs from the router; anything else maps to "other".
const known = new Set(["home", "learn", "practice", "progress", "final-exam", "notes"]);
return known.has(path) ? path : "other";
}
function getCurrentCert() {
const cert = window.UserData && window.UserData.getCertMode
? window.UserData.getCertMode()
: null;
if (cert) return cert;
// If chooser is showing (no cert picked yet), default to the first cert
// in the registry as a best-effort. The form lets the user override.
const exams = window.EXAMS || [];
return exams.length ? exams[0].id : "secplus";
}
function getCertLabel(certId) {
// Prefer the helper exposed by exams.js when available — keeps cert
// labels consistent across the whole app.
if (window.getExamLabel) return window.getExamLabel(certId);
const c = getCerts().find(x => x.id === certId);
return c ? c.label : certId;
}
function getTabLabel(tabId) {
const t = ALL_TABS.find(x => x.id === tabId);
return t ? t.label : tabId;
}
function getTypeLabel(typeId) {
const t = TYPES.find(x => x.id === typeId);
return t ? t.label : typeId;
}
// Submit a payload to one of our backend feedback endpoints. Uses the
// AuthStore's api() helper so the JWT bearer token is attached. Returns
// { ok: bool, message: string } from the server, or { ok: false,
// message: } on transport failure.
async function submitToApi(endpoint, payload) {
if (!window.AuthStore || !window.AuthStore.api) {
return { ok: false, message: "Not signed in." };
}
try {
const result = await window.AuthStore.api(endpoint, {
method: "POST",
body: JSON.stringify(payload),
});
// The api() helper returns the parsed JSON body; our endpoints
// always return { ok, message }.
return result || { ok: false, message: "Empty response from server." };
} catch (err) {
// api() throws on HTTP non-2xx. Surface a generic network error.
return {
ok: false,
message: (err && err.message) ? err.message : "Network error. Please try again.",
};
}
}
// ── Modal shell (reusable for both variants) ────────────────────────────
function Modal({ open, onClose, children, ariaLabel }) {
React.useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
// Prevent background scroll while modal is open.
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose]);
if (!open) return null;
return React.createElement(
"div",
{
className: "fb-modal-backdrop",
onClick: (e) => { if (e.target === e.currentTarget) onClose(); },
role: "dialog",
"aria-modal": "true",
"aria-label": ariaLabel || "Dialog",
},
React.createElement(
"div",
{ className: "fb-modal", onClick: (e) => e.stopPropagation() },
React.createElement(
"button",
{ className: "fb-modal-close", onClick: onClose, "aria-label": "Close" },
"×"
),
children
)
);
}
// ── Sign-in prompt (shown when logged-out user opens any feedback modal) ─
function SignInPrompt({ onClose, action }) {
function goLogin() {
onClose();
if (window.AuthNav && window.AuthNav.setView) window.AuthNav.setView("login");
}
function goSignup() {
onClose();
if (window.AuthNav && window.AuthNav.setView) window.AuthNav.setView("signup");
}
return React.createElement(
"div",
{ className: "fb-signin-prompt" },
React.createElement("h2", null, "Sign in required"),
React.createElement(
"p",
null,
"To ", action || "send feedback", " you'll need to be signed in. This lets us reach back if we have questions, and helps us keep this channel useful instead of spam."
),
React.createElement(
"div",
{ className: "fb-actions" },
React.createElement(
"button",
{ className: "btn btn-primary", onClick: goLogin },
"Sign in"
),
React.createElement(
"button",
{ className: "btn btn-secondary", onClick: goSignup },
"Create an account"
)
)
);
}
// ── Feedback form (full version with all fields) ────────────────────────
function FeedbackForm({ onClose }) {
const user = (window.AuthStore && window.AuthStore.user) || {};
const userEmail = user.email || "";
const userName = user.name || user.username || userEmail;
const initialTab = getCurrentTabId();
const initialCert = getCurrentCert();
const [type, setType] = React.useState("suggestion");
const [cert, setCert] = React.useState(initialCert);
const [pages, setPages] = React.useState(new Set([initialTab]));
const [subjectSuffix, setSubjectSuffix] = React.useState("");
const [message, setMessage] = React.useState("");
const [wantResponse, setWantResponse] = React.useState(false);
// Submission state machine: 'idle' → 'sending' → 'sent' (auto-close after 2s)
// └→ 'error' (stays on form, user can retry)
const [status, setStatus] = React.useState("idle");
const [errorMsg, setErrorMsg] = React.useState("");
// Subject prefix is auto-computed from type/cert/pages and is NOT editable.
// The user only types the descriptive suffix in the input below the prefix.
// Keeps the context tag consistent across submissions so we can triage by
// it server-side (eventually) and prevents accidentally deleting the tag.
const subjectPrefix = React.useMemo(() => {
const tabStr = pages.size === 1
? getTabLabel([...pages][0])
: pages.size > 1 ? `${pages.size} pages` : "general";
return `[${getCertLabel(cert)} / ${tabStr}] ${getTypeLabel(type)}:`;
}, [type, cert, pages]);
function togglePage(pageId) {
setPages(prev => {
const next = new Set(prev);
if (next.has(pageId)) next.delete(pageId);
else next.add(pageId);
return next;
});
}
async function handleSubmit() {
if (status === "sending") return; // guard double-clicks
if (!message.trim()) {
setErrorMsg("Please enter a message before sending.");
setStatus("error");
return;
}
if (pages.size === 0) {
setErrorMsg("Please select at least one page this applies to (or choose Other).");
setStatus("error");
return;
}
setStatus("sending");
setErrorMsg("");
const payload = {
type: getTypeLabel(type),
cert,
pages: [...pages].map(getTabLabel),
subject_prefix: subjectPrefix,
subject_suffix: subjectSuffix,
message,
want_response: wantResponse,
};
const result = await submitToApi(FEEDBACK_ENDPOINT, payload);
if (result.ok) {
setStatus("sent");
// Auto-close after 2s so the user sees the confirmation.
setTimeout(onClose, 2000);
} else {
setErrorMsg(result.message || "Couldn't send. Please try again.");
setStatus("error");
}
}
return React.createElement(
"div",
{ className: "fb-form" },
React.createElement("h2", null, "Send feedback"),
React.createElement(
"p",
{ className: "fb-form-intro" },
"Bug, suggestion, request, anything — we read every one. We'll reach back if you ask us to."
),
// Type
React.createElement(
"div",
{ className: "fb-field" },
React.createElement("label", { className: "fb-label" }, "Type"),
React.createElement(
"select",
{ className: "fb-select", value: type, onChange: (e) => setType(e.target.value) },
TYPES.map(t => React.createElement("option", { key: t.id, value: t.id }, t.label))
)
),
// Cert track
React.createElement(
"div",
{ className: "fb-field" },
React.createElement("label", { className: "fb-label" }, "Cert track"),
React.createElement(
"select",
{ className: "fb-select", value: cert, onChange: (e) => setCert(e.target.value) },
getCerts().map(c => React.createElement("option", { key: c.id, value: c.id }, c.label))
)
),
// Pages (multi-select via checkboxes)
React.createElement(
"div",
{ className: "fb-field" },
React.createElement(
"label",
{ className: "fb-label" },
"Pages this applies to (select all that apply)"
),
React.createElement(
"div",
{ className: "fb-checkbox-grid" },
ALL_TABS.map(t => React.createElement(
"label",
{ key: t.id, className: "fb-checkbox" },
React.createElement("input", {
type: "checkbox",
checked: pages.has(t.id),
onChange: () => togglePage(t.id),
}),
React.createElement("span", null, t.label)
))
)
),
// Subject — auto-prefix (uneditable) + user-typed suffix
React.createElement(
"div",
{ className: "fb-field" },
React.createElement("label", { className: "fb-label" }, "Subject"),
React.createElement(
"div",
{ className: "fb-subject-row" },
React.createElement(
"span",
{ className: "fb-subject-prefix", title: "Auto-generated from Type / Cert / Pages" },
subjectPrefix
),
React.createElement("input", {
type: "text",
className: "fb-input fb-subject-suffix",
value: subjectSuffix,
onChange: (e) => setSubjectSuffix(e.target.value),
placeholder: "Short summary of your message",
})
)
),
// Message
React.createElement(
"div",
{ className: "fb-field" },
React.createElement("label", { className: "fb-label" }, "Message"),
React.createElement("textarea", {
className: "fb-textarea",
rows: 7,
value: message,
onChange: (e) => setMessage(e.target.value),
placeholder: "Tell us what's on your mind…",
})
),
// Want response
React.createElement(
"div",
{ className: "fb-field" },
React.createElement(
"label",
{ className: "fb-checkbox fb-checkbox-large" },
React.createElement("input", {
type: "checkbox",
checked: wantResponse,
onChange: (e) => setWantResponse(e.target.checked),
}),
React.createElement(
"span",
null,
"I'd like a response — okay to reach me at ",
React.createElement("strong", null, userEmail)
)
)
),
// Status banner (success / error)
status === "sent" && React.createElement(
"div",
{ className: "fb-status fb-status-ok" },
"✓ Thanks — we got your message."
),
status === "error" && React.createElement(
"div",
{ className: "fb-status fb-status-err" },
errorMsg || "Something went wrong. Please try again."
),
// Actions
React.createElement(
"div",
{ className: "fb-actions" },
React.createElement(
"button",
{
className: "btn btn-primary",
onClick: handleSubmit,
disabled: status === "sending" || status === "sent",
},
status === "sending" ? "Sending…" : status === "sent" ? "Sent ✓" : "Send feedback"
),
React.createElement(
"button",
{ className: "btn btn-secondary", onClick: onClose, disabled: status === "sending" },
status === "sent" ? "Close" : "Cancel"
)
)
);
}
// ── Cert request form (simpler — single textarea + submit) ──────────────
// RequestForm renders the same modal shape for either "request a cert" or
// "request a career path" flows. The two are nearly identical user-facing
// — free-form name + optional reason + optional response opt-in — and the
// backend uses one endpoint (/api/cert-request) with a request_type field
// to triage in the requests@ inbox.
//
// kind = "cert" | "career_path"
function RequestForm({ onClose, kind }) {
const isCareer = kind === "career_path";
const user = (window.AuthStore && window.AuthStore.user) || {};
const userEmail = user.email || "";
const userName = user.name || user.username || userEmail;
const labels = isCareer ? {
heading: "Request a new career path",
intro: "Want us to add a career path that isn't here yet? Tell us which one and why it matters to you. We prioritize new paths based on requests.",
fieldLabel: "Which career path?",
fieldPlaceholder: "e.g. DevOps Engineer, Database Administrator, Game Developer",
reasonPlaceholder: "What kind of role are you aiming for? What certs would matter most for it?",
validationMissing: "Please enter which career path you'd like us to add.",
} : {
heading: "Request a new certification",
intro: "Want us to add a cert track that isn't here? Let us know which one and what you'd hope to study for. We prioritize tracks based on requests.",
fieldLabel: "Which certification?",
fieldPlaceholder: "e.g. Network+, AWS SAA-C03, CISSP",
reasonPlaceholder: "Why this cert, when you plan to take it, what topics matter most to you, etc.",
validationMissing: "Please enter which cert you'd like us to add.",
};
const [itemName, setItemName] = React.useState("");
const [reason, setReason] = React.useState("");
const [wantResponse, setWantResponse] = React.useState(false);
const [status, setStatus] = React.useState("idle");
const [errorMsg, setErrorMsg] = React.useState("");
async function handleSubmit() {
if (status === "sending") return;
if (!itemName.trim()) {
setErrorMsg(labels.validationMissing);
setStatus("error");
return;
}
setStatus("sending");
setErrorMsg("");
const payload = {
cert_name: itemName, // backend reuses cert_name field for both kinds
reason,
want_response: wantResponse,
request_type: isCareer ? "career_path" : "cert",
};
const result = await submitToApi(CERT_REQUEST_ENDPOINT, payload);
if (result.ok) {
setStatus("sent");
setTimeout(onClose, 2000);
} else {
setErrorMsg(result.message || "Couldn't send. Please try again.");
setStatus("error");
}
}
return React.createElement(
"div",
{ className: "fb-form" },
React.createElement("h2", null, labels.heading),
React.createElement(
"p",
{ className: "fb-form-intro" },
labels.intro
),
React.createElement(
"div",
{ className: "fb-field" },
React.createElement("label", { className: "fb-label" }, labels.fieldLabel),
React.createElement("input", {
type: "text",
className: "fb-input",
value: itemName,
onChange: (e) => setItemName(e.target.value),
placeholder: labels.fieldPlaceholder,
})
),
React.createElement(
"div",
{ className: "fb-field" },
React.createElement(
"label",
{ className: "fb-label" },
"Anything else? (optional)"
),
React.createElement("textarea", {
className: "fb-textarea",
rows: 5,
value: reason,
onChange: (e) => setReason(e.target.value),
placeholder: labels.reasonPlaceholder,
})
),
React.createElement(
"div",
{ className: "fb-field" },
React.createElement(
"label",
{ className: "fb-checkbox fb-checkbox-large" },
React.createElement("input", {
type: "checkbox",
checked: wantResponse,
onChange: (e) => setWantResponse(e.target.checked),
}),
React.createElement(
"span",
null,
"I'd like a response — okay to reach me at ",
React.createElement("strong", null, userEmail)
)
)
),
// Status banner
status === "sent" && React.createElement(
"div",
{ className: "fb-status fb-status-ok" },
"✓ Thanks — we got your request."
),
status === "error" && React.createElement(
"div",
{ className: "fb-status fb-status-err" },
errorMsg || "Something went wrong. Please try again."
),
React.createElement(
"div",
{ className: "fb-actions" },
React.createElement(
"button",
{
className: "btn btn-primary",
onClick: handleSubmit,
disabled: status === "sending" || status === "sent",
},
status === "sending" ? "Sending…" : status === "sent" ? "Sent ✓" : "Send request"
),
React.createElement(
"button",
{ className: "btn btn-secondary", onClick: onClose, disabled: status === "sending" },
status === "sent" ? "Close" : "Cancel"
)
)
);
}
// Backwards-compatible cert-request wrapper. Existing FeedbackRequestCertBtn
// and any other callers that import CertRequestForm continue to work.
function CertRequestForm({ onClose }) {
return React.createElement(RequestForm, { onClose, kind: "cert" });
}
// Career-path request wrapper — used by the Career Planner catalog page.
function CareerPathRequestForm({ onClose }) {
return React.createElement(RequestForm, { onClose, kind: "career_path" });
}
// ── Public components ───────────────────────────────────────────────────
/** Site-wide footer with "Send feedback" link. Renders on every view. */
function FeedbackFooter() {
const [open, setOpen] = React.useState(false);
const isLoggedIn = !!(window.AuthStore && window.AuthStore.isLoggedIn);
return React.createElement(
React.Fragment,
null,
React.createElement(
"footer",
{ className: "fb-footer" },
React.createElement(
"button",
{ className: "fb-footer-link", onClick: () => setOpen(true) },
"Send feedback"
)
),
React.createElement(
Modal,
{
open,
onClose: () => setOpen(false),
ariaLabel: isLoggedIn ? "Send feedback" : "Sign in required",
},
isLoggedIn
? React.createElement(FeedbackForm, { onClose: () => setOpen(false) })
: React.createElement(SignInPrompt, { onClose: () => setOpen(false), action: "send feedback" })
)
);
}
/** Chooser-screen button: "Request a new certification". */
function FeedbackRequestCertBtn() {
const [open, setOpen] = React.useState(false);
const isLoggedIn = !!(window.AuthStore && window.AuthStore.isLoggedIn);
return React.createElement(
React.Fragment,
null,
React.createElement(
"button",
{ className: "fb-request-cert-btn", onClick: () => setOpen(true) },
"+ Request a new certification"
),
React.createElement(
Modal,
{
open,
onClose: () => setOpen(false),
ariaLabel: isLoggedIn ? "Request a new certification" : "Sign in required",
},
isLoggedIn
? React.createElement(CertRequestForm, { onClose: () => setOpen(false) })
: React.createElement(SignInPrompt, { onClose: () => setOpen(false), action: "request a new certification" })
)
);
}
window.FeedbackFooter = FeedbackFooter;
window.FeedbackRequestCertBtn = FeedbackRequestCertBtn;
window.FeedbackModal = Modal;
window.FeedbackSignInPrompt = SignInPrompt;
window.CareerPathRequestForm = CareerPathRequestForm;
})();