Customers can view and cancel their booking through a link in the e-mail. Fixes #18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 15:25:46 +02:00
co-authored by Claude Opus 5.5
parent d610479c52
commit 91c2a06a3e
29 changed files with 797 additions and 63 deletions
+12 -2
View File
@@ -1,6 +1,6 @@
use chrono::{Datelike, Local};
use leptos::prelude::*;
use crate::backend::data::{ApiResponse, ReservationState, ResSumWithItems};
use crate::backend::data::{ApiResponse, ReservationState, ReservationSum, ResSumWithItems};
use crate::backend::reservation::{month_chart, reservations_in_month, year_chart, years};
use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog};
use crate::components::theme::Theme;
@@ -8,6 +8,16 @@ use crate::components::user_menu::MenuOpener;
use crate::locales::{loc_date, show_day, trl};
use crate::pages::new_reservations::CancelDialog;
/// Translation key of the state of a booking
fn state_key(summary: &ReservationSum) -> &'static str {
match summary.state {
ReservationState::New => "New",
ReservationState::Approved => "Approved",
ReservationState::Canceled if summary.canceled_by_customer => "Canceled by customer",
ReservationState::Canceled => "Canceled",
}
}
#[component]
fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView {
let menu = MenuOpener::new();
@@ -187,7 +197,7 @@ pub fn bookings() -> impl IntoView {
<td>{loc_date(data.summary.date)}</td>
<td>{data.customer.full_name.clone()}</td>
<td>{data.summary.price.to_string()}</td>
<td>{trl(&data.summary.state.to_string())}</td>
<td>{trl(state_key(&data.summary))}</td>
<td><button type="button" class="btn"
on:click=move |_| {
res_detail.set(data.clone());
+131
View File
@@ -0,0 +1,131 @@
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use crate::backend::data::{ApiResponse, PublicBooking, ReservationState};
use crate::backend::reservation::{get_booking, CancelBooking};
use crate::locales::{loc_date, trl};
use crate::pages::public::PublicShell;
/// The route of the link sent in e-mails: the booking and the possibility to cancel it
#[component]
pub fn BookingPage() -> impl IntoView {
view! {
<PublicShell>
<Booking/>
</PublicShell>
}
}
#[component]
fn booking_detail(booking: PublicBooking, uuid: String, cancel: ServerAction<CancelBooking>) -> impl IntoView {
// Cancelling is asked twice, a wrong tap must not cancel a booking
let confirming = RwSignal::new(false);
let uuid = StoredValue::new(uuid);
let canceled = booking.state == ReservationState::Canceled;
let deadline = format!("{} {}", loc_date(booking.cancel_until.date())(), booking.cancel_until.time().format("%H:%M"));
let state_class = match booking.state {
ReservationState::New => "badge bg-label-warning",
ReservationState::Approved => "badge bg-label-success",
ReservationState::Canceled => "badge bg-label-secondary",
};
let state_text = match booking.state {
ReservationState::New => "Waiting for approval",
ReservationState::Approved => "Approved",
ReservationState::Canceled => "Canceled",
};
view! {
<div class="mb-3"><span class=state_class>{trl(state_text)}</span></div>
<div class="form-label">{trl("Date")}</div>
<div class="mb-3">{loc_date(booking.date)}</div>
<div class="form-label">{trl("Booked")}</div>
<ul class="list-unstyled mb-3">
{booking.items.iter().map(|item| view! {
<li>{item.name.clone()}": "{item.from.format("%H:%M").to_string()}" – "{item.to.format("%H:%M").to_string()}</li>
}).collect_view()}
</ul>
<div class="form-label">{trl("Total price of booking")}</div>
<div class="mb-4">{format!("{} Kč", booking.price)}</div>
{if booking.can_cancel {
view! {
<p>{trl("You can cancel this booking until")}" "{deadline}"."</p>
<Show
when=move || confirming.get()
fallback=move || view! {
<button type="button" class="btn btn-outline-secondary" on:click=move |_| confirming.set(true)>
{trl("Cancel booking")}
</button>
}>
<p><b>{trl("Do you really want to cancel this booking?")}</b></p>
<button type="button" class="btn btn-primary me-2" disabled=move || cancel.pending().get()
on:click=move |_| {
cancel.dispatch(CancelBooking { uuid: uuid.get_value() });
}>
{trl("Yes, cancel booking")}
</button>
<button type="button" class="btn btn-outline-secondary" on:click=move |_| confirming.set(false)>
{trl("No")}
</button>
</Show>
}.into_any()
} else if canceled {
view! {
<p>{trl("This booking is cancelled.")}</p>
}.into_any()
} else {
view! {
<p>{trl("This booking can no longer be cancelled, it was possible until")}" "{deadline}"."</p>
}.into_any()
}}
}
}
#[component]
fn booking() -> impl IntoView {
let params = use_params_map();
let uuid = move || params.read().get("uuid").unwrap_or_default();
let cancel = ServerAction::<CancelBooking>::new();
// Loaded again after an attempt to cancel, whatever its result
let booking = Resource::new_blocking(move || (uuid(), cancel.version().get()), |(uuid, _)| get_booking(uuid));
view! {
<div class="card-body public-form">
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title"><i class="bx bx-calendar-check"></i>" "{trl("Your booking")}</h5>
{move || match cancel.value().get() {
Some(Ok(ApiResponse::Error(e))) => view! {
<div class="alert alert-danger">{trl(&e)}</div>
}.into_any(),
Some(Err(e)) => view! {
<div class="alert alert-danger">{trl("Something went wrong")}": "{e.to_string()}</div>
}.into_any(),
_ => view! {<span></span>}.into_any()
}}
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || {
booking.get().map(|b| match b {
Ok(ApiResponse::Data(b)) => view! {
<BookingDetail booking=b uuid=uuid() cancel=cancel/>
}.into_any(),
Ok(ApiResponse::Error(e)) => view! {
<p>{trl(&e)}</p>
}.into_any(),
Err(e) => view! {
<div><p>{trl("Error loading data")}</p><p>{e.to_string()}</p></div>
}.into_any()
})
}}
</Transition>
<div class="mt-4">
<a href="/">{trl("New booking")}</a>
</div>
</div>
</div>
</div>
</div>
</div>
}
}
+17
View File
@@ -1,3 +1,4 @@
use crate::backend::cancellation::MAX_CANCEL_HOURS;
use crate::backend::data::Company;
use crate::backend::company::UpdateCompany;
use crate::components::modal_box::DialogOpener;
@@ -76,6 +77,22 @@ pub fn CompanyEdit(
/>
</div>
</div>
<div class="row">
<div class="col mb-3">
<label for="cancelHours" class="form-label">{trl("Customers can cancel a booking until (hours before its start)")}</label>
<input
type="number"
id="cancelHours"
class="form-control"
min="0"
max=MAX_CANCEL_HOURS.to_string()
step="1"
prop:value={move || company.get().cancel_hours.to_string()}
name="company[cancel_hours]"
/>
<div class="form-text">{trl("0 means until the start of the booking.")}</div>
</div>
</div>
</DataForm>
}
}
+3 -1
View File
@@ -30,7 +30,9 @@ pub fn CompanyInfo() -> impl IntoView {
<p><b>{d.name}</b></p>
<p>{d.street}" "{d.house_number}<br/>
{d.zip_code}" "{d.city}
</p></div>}.into_any()
</p>
<p>{trl("Customers can cancel a booking until (hours before its start)")}": "{d.cancel_hours}</p>
</div>}.into_any()
}
ApiResponse::Error(_) => {view! {<div><p>{trl("Error loading data")}</p></div>}.into_any()}
}
+7
View File
@@ -7,6 +7,7 @@ use crate::pages::mail_view::MailView;
pub fn mail_settings() -> impl IntoView {
view! {
<h1>{trl("Mail settings")}</h1>
<p>{trl("In the texts, #date# is replaced by the date of the booking, #summary# by the booked items and #link# by the link to the page where the customer can view and cancel the booking. E-mails for the customer about a new and an approved booking get the link at the end when their text does not contain #link#.")}</p>
<div class="row mb-5">
<div class="col-md">
<MailView title="New booking" mail_type=MessageType::NewReservation/>
@@ -23,5 +24,11 @@ pub fn mail_settings() -> impl IntoView {
<MailView title="Booking canceled" mail_type=MessageType::ReservationCanceled/>
</div>
</div>
<div class="row mb-5">
<div class="col-md">
<MailView title="Booking canceled by customer - for staff" mail_type=MessageType::CustomerCanceled/>
</div>
<div class="col-md"></div>
</div>
}
}
+1
View File
@@ -4,6 +4,7 @@ mod company_info;
mod company_edit;
pub mod login;
pub mod public;
pub mod booking;
pub mod profile_edit;
pub mod change_pwd;
mod users;
+31 -37
View File
@@ -109,22 +109,37 @@ fn time_selector(
}
}
/// The public route: the booking form in the colours the customer has set.
/// Styles in `assets/css/public.css` are scoped by the wrapper, so the form embedded
/// in the admin portal is not affected.
/// Wrapper of the public pages: the colours and layout the customer has set, and the banner.
/// Styles in `assets/css/public.css` are scoped by it, so the form embedded in the admin portal
/// is not affected.
#[component]
pub fn PublicPage() -> impl IntoView {
pub fn PublicShell(
/// Show the text under the banner
#[prop(optional)] with_text: bool,
children: ChildrenFn) -> impl IntoView {
let appearance = Resource::new_blocking(||(), move |_| get_appearance());
view! {
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || {
appearance.get().map(|a| {
// Default look when the settings can't be loaded, the form reports the error itself
// Default look when the settings can't be loaded, the page reports its own errors
let a = a.unwrap_or_default();
let title = a.title.clone().unwrap_or_default();
view! {
<div class=format!("public-page {}", a.layout.classes()) style=a.colors.style()>
<Public/>
{a.has_banner().then(|| view! {
<div class="public-banner" style=a.banner_style()>
{a.logo_url().map(|url| view! {
<img class="public-banner-logo" src=url alt=""/>
})}
<h1 class="public-banner-title">{title}</h1>
</div>
})}
{with_text.then(|| view! {
<div class="public-banner-text" inner_html=a.text.clone().unwrap_or_default()></div>
})}
{children()}
</div>
}
})
@@ -133,6 +148,16 @@ pub fn PublicPage() -> impl IntoView {
}
}
/// The public route: the booking form
#[component]
pub fn PublicPage() -> impl IntoView {
view! {
<PublicShell with_text=true>
<Public/>
</PublicShell>
}
}
#[component]
pub fn Public() -> impl IntoView {
let day = RwSignal::new(NaiveDate::default());
@@ -147,7 +172,6 @@ pub fn Public() -> impl IntoView {
let is_pending = RwSignal::new(None);
let active_str = RwSignal::new("true".to_string());
let get_customer = Resource::new_blocking(||(), move |_| get_remembered());
let appearance = Resource::new_blocking(||(), move |_| get_appearance());
let customer = RwSignal::new(Customer::default());
Effect::new(move |_| {
@@ -163,36 +187,6 @@ pub fn Public() -> impl IntoView {
slots=slots.write_only()
captcha_state=is_pending.read_only()/>
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || {
appearance.get().map(|a| match a {
Ok(a) => {
let title = a.title.clone().unwrap_or_default();
view! {
<div>
{a.has_banner().then(|| view! {
<div class="public-banner" style=a.banner_style()>
{a.logo_url().map(|url| view! {
<img class="public-banner-logo" src=url alt=""/>
})}
<h1 class="public-banner-title">{title}</h1>
</div>
})}
<div>
<div class="public-banner-text" inner_html={a.text.unwrap_or("".to_string())}>
</div>
</div>
</div>
}.into_any()
},
Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
<p>{e.to_string()}</p></div>
}.into_any()}
})
}}
</Transition>
<div class="card-body public-form">
<ActionForm
// Capture phase, so the form is validated and the captcha solved before it is dispatched