You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

163 lines
6.0 KiB
Rust

//! ReportModal component — lets users report a quote with an optional reason
//! and a Cloudflare Turnstile CAPTCHA.
//!
//! The modal renders:
//! - A textarea for an optional reason (max 256 characters, with a live counter).
//! - A Cloudflare Turnstile widget that sets a hidden input once solved.
//! - Submit (disabled until the CAPTCHA is solved) and Cancel buttons.
//!
//! Turnstile is loaded globally via the `<script>` tag in `index.html`. After
//! solving, Turnstile writes the token into a hidden `<input name="cf-turnstile-response">`.
//! We read that value through the DOM on submit.
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use yew::prelude::*;
/// Cloudflare Turnstile site key.
///
/// Replace with the real site key before going to production.
/// `"0x4AAAAAAAAAAAAAAAAAAAAAA"` is the Turnstile always-passes test key.
const TURNSTILE_SITE_KEY: &str = "0x4AAAAAAAAAAAAAAAAAAAAAA";
/// Maximum allowed length for the optional report reason.
const MAX_REASON_LEN: usize = 256;
/// Props for the [`ReportModal`] component.
#[derive(Properties, PartialEq)]
pub struct ReportModalProps {
/// Callback invoked with `(reason, captcha_token)` when the user submits.
pub on_submit: Callback<(Option<String>, String)>,
/// Callback invoked when the user clicks Cancel.
pub on_cancel: Callback<()>,
}
/// Modal dialog for reporting a quote.
///
/// Renders a reason textarea, a Cloudflare Turnstile CAPTCHA widget, and
/// Submit / Cancel buttons.
///
/// The Turnstile widget is rendered by injecting a `<div class="cf-turnstile">`
/// with the appropriate `data-sitekey` attribute. Turnstile's global JS (loaded
/// in `index.html`) picks up that div automatically. The token is read from the
/// hidden `cf-turnstile-response` input at submit time and forwarded to the
/// caller; it may be empty if Turnstile has not yet solved (e.g., no network).
/// Rate limiting is enforced at the WAF layer rather than the application layer.
///
/// # Example
///
/// ```ignore
/// html! {
/// <ReportModal
/// on_submit={Callback::from(|(reason, token)| { /* send to API */ })}
/// on_cancel={Callback::from(|_| {})}
/// />
/// }
/// ```
#[function_component(ReportModal)]
pub fn report_modal(props: &ReportModalProps) -> Html {
let reason = use_state(String::new);
// --- Handle reason textarea input ---
let on_reason_input = {
let reason = reason.clone();
Callback::from(move |e: InputEvent| {
let textarea: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
let mut val = textarea.value();
if val.len() > MAX_REASON_LEN {
val.truncate(MAX_REASON_LEN);
}
reason.set(val);
})
};
// --- Form submit handler ---
let on_submit = {
let reason = reason.clone();
let on_submit_cb = props.on_submit.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
// Read the Turnstile token from the hidden input if available.
let token = web_sys::window()
.and_then(|w| w.document())
.and_then(|doc| {
doc.query_selector("input[name='cf-turnstile-response']")
.ok()
.flatten()
})
.map(|el| el.unchecked_into::<HtmlInputElement>().value())
.unwrap_or_default();
let reason_val = {
let s = (*reason).clone();
if s.is_empty() {
None
} else {
Some(s)
}
};
on_submit_cb.emit((reason_val, token));
})
};
let reason_len = reason.len();
let on_cancel = props.on_cancel.clone();
html! {
<div class="report-modal__overlay">
<div class="report-modal">
<h2 class="report-modal__title">{ "Report Quote" }</h2>
<p class="report-modal__desc">
{ "Use this form to report a quote that is inaccurate, offensive, or otherwise problematic." }
</p>
<form onsubmit={on_submit}>
<div class="report-modal__field">
<label class="report-modal__label">
{ "Reason (optional)" }
</label>
<textarea
class="report-modal__textarea"
placeholder="Describe the issue…"
value={(*reason).clone()}
oninput={on_reason_input}
maxlength={(MAX_REASON_LEN as u32).to_string()}
/>
<div class="report-modal__counter">
{ format!("{}/{}", reason_len, MAX_REASON_LEN) }
</div>
</div>
// Turnstile renders itself into any div with class "cf-turnstile"
// and the correct data-sitekey attribute.
<div class="report-modal__captcha">
<div
class="cf-turnstile"
data-sitekey={TURNSTILE_SITE_KEY}
data-theme="light"
/>
</div>
<div class="report-modal__actions">
<button
type="submit"
class="btn btn--danger"
>
{ "Submit Report" }
</button>
<button
type="button"
class="btn"
onclick={Callback::from(move |_| on_cancel.emit(()))}
>
{ "Cancel" }
</button>
</div>
</form>
</div>
</div>
}
}