feat(quotesdb): add report button with modal on quote page
Adds a Report button to /quotes/:id that opens a modal containing:
- Optional reason textarea (max 256 chars) with live character counter
- Cloudflare Turnstile CAPTCHA widget (always-passes test sitekey as const)
- Submit button disabled until CAPTCHA token is non-empty
- Cancel button
On submit POSTs { reason?, captcha_token } to POST /api/quotes/:id/report.
Shows a success banner on 201 or an error via ErrorDisplay on failure.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
parent
a6949dc1fe
commit
201dfe55a0
@ -0,0 +1,212 @@
|
||||
//! 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 Submit button is disabled until the Turnstile
|
||||
/// widget has been solved (i.e., the hidden input carries a non-empty token).
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// # 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);
|
||||
// Whether the Turnstile CAPTCHA has been solved (token is non-empty).
|
||||
let captcha_solved = use_state(|| false);
|
||||
|
||||
// --- 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);
|
||||
})
|
||||
};
|
||||
|
||||
// --- Poll the hidden Turnstile input for a token on the "input" event ---
|
||||
// Turnstile fires a synthetic "input" event on the hidden field when solved.
|
||||
// We wire this up via use_effect so we can attach the listener to the DOM.
|
||||
{
|
||||
let captcha_solved = captcha_solved.clone();
|
||||
use_effect(move || {
|
||||
let window = web_sys::window().expect("no global window");
|
||||
let document = window.document().expect("no document");
|
||||
|
||||
// Closure to check the hidden input value.
|
||||
let captcha_solved_clone = captcha_solved.clone();
|
||||
let check_fn = wasm_bindgen::closure::Closure::<dyn Fn()>::new(move || {
|
||||
let document = web_sys::window()
|
||||
.and_then(|w| w.document())
|
||||
.expect("document");
|
||||
if let Some(input_el) = document
|
||||
.query_selector("input[name='cf-turnstile-response']")
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
let input: HtmlInputElement = input_el.unchecked_into();
|
||||
captcha_solved_clone.set(!input.value().is_empty());
|
||||
}
|
||||
});
|
||||
|
||||
// Attach the listener to the document so it catches the event
|
||||
// regardless of where Turnstile fires it.
|
||||
let _ = document
|
||||
.add_event_listener_with_callback("input", check_fn.as_ref().unchecked_ref());
|
||||
|
||||
// Also run once immediately in case the widget already solved
|
||||
// (e.g., page revisit).
|
||||
check_fn
|
||||
.as_ref()
|
||||
.unchecked_ref::<js_sys::Function>()
|
||||
.call0(&wasm_bindgen::JsValue::NULL)
|
||||
.ok();
|
||||
|
||||
// Keep the closure alive until the component unmounts.
|
||||
check_fn.forget();
|
||||
|| ()
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
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();
|
||||
|
||||
if token.is_empty() {
|
||||
return; // Should not happen — button is disabled until solved.
|
||||
}
|
||||
|
||||
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 submit_disabled = !*captcha_solved;
|
||||
|
||||
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"
|
||||
disabled={submit_disabled}
|
||||
>
|
||||
{ "Submit Report" }
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
onclick={Callback::from(move |_| on_cancel.emit(()))}
|
||||
>
|
||||
{ "Cancel" }
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue