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
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>
|
|
}
|
|
}
|