Added customizable colours of the public page with palettes and live preview. Fixes #14
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::backend::appearance::{DeleteBanner, get_appearance, UpdateAppearance};
|
||||
use crate::backend::data::Appearance;
|
||||
use crate::backend::appearance::{DeleteBanner, get_appearance, UpdateAppearance, UpdateColors};
|
||||
use crate::backend::color::{Colors, PALETTES};
|
||||
use crate::backend::data::{ApiResponse, Appearance};
|
||||
use crate::components::data_form::{DataForm, QuestionDialog};
|
||||
use crate::components::modal_box::DialogOpener;
|
||||
use crate::locales::trl;
|
||||
@@ -61,6 +62,174 @@ fn delete_banner(opener: DialogOpener) -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn color_input(
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
colors: RwSignal<Colors>,
|
||||
get: fn(&Colors) -> String,
|
||||
set: fn(&mut Colors, String)) -> impl IntoView {
|
||||
view! {
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<label for=id class="form-label mb-0">{trl(label)}</label>
|
||||
<input
|
||||
type="color"
|
||||
id=id
|
||||
class="form-control form-control-color"
|
||||
prop:value=move || get(&colors.get())
|
||||
on:input=move |ev| colors.update(|c| set(c, event_target_value(&ev)))
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample of the public page in the colours being edited. It uses the same classes and the same
|
||||
/// stylesheet (assets/css/public.css) as the page itself.
|
||||
#[component]
|
||||
fn preview(colors: RwSignal<Colors>, appearance: ReadSignal<Appearance>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="public-page public-preview" style=move || colors.get().style()>
|
||||
{move || {
|
||||
let app = appearance.get();
|
||||
app.banner.is_some().then(|| view! {
|
||||
<div class="public-banner" style=app.banner_style()>
|
||||
<h1 class="public-banner-title">{app.title.clone().unwrap_or_default()}</h1>
|
||||
</div>
|
||||
})
|
||||
}}
|
||||
<div class="public-banner-text" inner_html=move || appearance.get().text.unwrap_or_default()></div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="bx bx-basket"></i>" "{trl("Booking")}</h5>
|
||||
<div class="mb-3">
|
||||
<label for="preview_date" class="form-label">{trl("Date")}</label>
|
||||
<input type="date" id="preview_date" class="form-control"/>
|
||||
</div>
|
||||
<div class="form-label">{trl("Time slot")}</div>
|
||||
<div class="mb-3">
|
||||
<input type="checkbox" class="btn-check" id="preview_slot1" autocomplete="off"/>
|
||||
<label class="btn btn-outline-primary" for="preview_slot1">"17:00"</label>
|
||||
<input type="checkbox" class="btn-check" id="preview_slot2" autocomplete="off" checked=true/>
|
||||
<label class="btn btn-outline-primary" for="preview_slot2">"18:00"</label>
|
||||
<input type="checkbox" class="btn-check" id="preview_slot3" autocomplete="off" disabled=true/>
|
||||
<label class="btn btn-outline-primary" for="preview_slot3">"19:00"</label>
|
||||
<input type="checkbox" class="btn-check" id="preview_slot4" autocomplete="off"/>
|
||||
<label class="btn btn-outline-primary" for="preview_slot4">"20:00"</label>
|
||||
</div>
|
||||
<div class="form-label">{trl("Total price of booking")}</div>
|
||||
<div>"250 Kč"</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="bx bxs-contact"></i>" "{trl("Who is booking")}</h5>
|
||||
<div class="mb-3">
|
||||
<label for="preview_name" class="form-label">{trl("Full name")}</label>
|
||||
<input type="text" id="preview_name" class="form-control" placeholder=trl("Enter full name")/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<input type="checkbox" id="preview_remember" class="form-check-input" checked=true/>
|
||||
" "
|
||||
<label for="preview_remember" class="form-label">{trl("Remember for next time")}</label>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<button type="button" class="btn btn-primary">{trl("Book")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn color_settings(colors: RwSignal<Colors>, saved: RwSignal<Colors>) -> impl IntoView {
|
||||
let save = ServerAction::<UpdateColors>::new();
|
||||
let sent = StoredValue::new(Colors::default());
|
||||
let dirty = move || colors.get() != saved.get();
|
||||
|
||||
Effect::new(move |_| {
|
||||
if let Some(Ok(ApiResponse::Data(_))) = save.value().get() {
|
||||
saved.set(sent.get_value());
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="form-label">{trl("Palettes")}</div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
{PALETTES.iter().enumerate().map(|(index, (name, primary, background, _, text))| view! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn p-0 border"
|
||||
style="width: 3rem; height: 2rem; overflow: hidden; display: inline-flex"
|
||||
title=trl(name)
|
||||
aria-label=trl(name)
|
||||
on:click=move |_| {
|
||||
if let Some(palette) = Colors::palette(index) {
|
||||
colors.set(palette);
|
||||
}
|
||||
}>
|
||||
<span style=format!("flex: 1; height: 100%; background: {}", primary)></span>
|
||||
<span style=format!("flex: 1; height: 100%; background: {}", background)></span>
|
||||
<span style=format!("flex: 1; height: 100%; background: {}", text)></span>
|
||||
</button>
|
||||
}).collect_view()}
|
||||
</div>
|
||||
<ColorInput id="color_primary" label="Primary colour" colors=colors
|
||||
get=|c| c.primary.clone() set=|c, v| c.primary = v/>
|
||||
<ColorInput id="color_background" label="Page background" colors=colors
|
||||
get=|c| c.background.clone() set=|c, v| c.background = v/>
|
||||
<ColorInput id="color_card" label="Card background" colors=colors
|
||||
get=|c| c.card.clone() set=|c, v| c.card = v/>
|
||||
<ColorInput id="color_text" label="Text colour" colors=colors
|
||||
get=|c| c.text.clone() set=|c, v| c.text = v/>
|
||||
{move || colors.get().contrast_issues().into_iter().map(|issue| view! {
|
||||
<div class="text-warning mb-1"><i class="bx bx-error"></i>" "{trl(issue.message())}</div>
|
||||
}).collect_view()}
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-primary me-2" on:click=move |_| {
|
||||
sent.set_value(colors.get());
|
||||
save.dispatch(UpdateColors { colors: colors.get() });
|
||||
}>
|
||||
{trl("Save")}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" on:click=move |_| colors.set(Colors::default())>
|
||||
{trl("Reset to default")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{move || {
|
||||
if dirty() {
|
||||
return view! {<span class="text-muted">{trl("Unsaved changes")}</span>}.into_any()
|
||||
}
|
||||
|
||||
match save.value().get() {
|
||||
Some(Ok(ApiResponse::Data(_))) => view! {
|
||||
<span class="text-success">{trl("Colours saved")}</span>
|
||||
}.into_any(),
|
||||
_ => view! {<span></span>}.into_any()
|
||||
}
|
||||
}}
|
||||
{move || match save.value().get() {
|
||||
Some(Ok(ApiResponse::Error(e))) => view! {
|
||||
<div class="alert alert-danger mt-2">{trl(&e)}</div>
|
||||
}.into_any(),
|
||||
Some(Err(e)) => view! {
|
||||
<div class="alert alert-danger mt-2">"Server error: " {e.to_string()}</div>
|
||||
}.into_any(),
|
||||
_ => view! {<span></span>}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn appearance() -> impl IntoView {
|
||||
let title_edit = DialogOpener::new();
|
||||
@@ -68,6 +237,24 @@ pub fn appearance() -> impl IntoView {
|
||||
let del_dialog = DialogOpener::new();
|
||||
let appearance = Resource::new(move || title_edit.visible() || text_edit.visible() || del_dialog.visible(), |_| get_appearance());
|
||||
let app_edit = RwSignal::new(Appearance::default());
|
||||
let colors = RwSignal::new(Colors::default());
|
||||
let saved_colors = RwSignal::new(Colors::default());
|
||||
let app_preview = RwSignal::new(Appearance::default());
|
||||
let colors_loaded = StoredValue::new(false);
|
||||
|
||||
// Runs in the browser only, so the server and the browser render the same markup first.
|
||||
// The data are loaded again after every dialog; that must not throw away colours being edited.
|
||||
Effect::new(move |_| {
|
||||
if let Some(Ok(app)) = appearance.get() {
|
||||
if !colors_loaded.get_value() {
|
||||
colors.set(app.colors.clone());
|
||||
saved_colors.set(app.colors.clone());
|
||||
colors_loaded.set_value(true);
|
||||
}
|
||||
app_preview.set(app);
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<EditTitle opener=title_edit appearance=app_edit.read_only()/>
|
||||
<EditText opener=text_edit appearance=app_edit.read_only()/>
|
||||
@@ -125,5 +312,23 @@ pub fn appearance() -> impl IntoView {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-lg-8 order-lg-2 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{trl("Preview of the public page")}</h5>
|
||||
<Preview colors=colors appearance=app_preview.read_only()/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 order-lg-1 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{trl("Colours")}</h5>
|
||||
<ColorSettings colors=colors saved=saved_colors/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,30 @@ 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.
|
||||
#[component]
|
||||
pub fn PublicPage() -> 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 colours when the settings can't be loaded, the form reports the error itself
|
||||
let style = a.map(|a| a.colors).unwrap_or_default().style();
|
||||
view! {
|
||||
<div class="public-page" style=style>
|
||||
<Public/>
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Transition>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Public() -> impl IntoView {
|
||||
let day = RwSignal::new(NaiveDate::default());
|
||||
|
||||
Reference in New Issue
Block a user