/* 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; })();