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:
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user