diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index 70e4c0b..a94e89c 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -48,6 +48,6 @@ paths: - Custom styles go to a CSS file in `assets/css/` and must be linked with `` in `src/components/header.rs` (order matters: after the vendor files to override them). `style/main.scss` is not linked anywhere and has no effect. Never edit `assets/vendor/` (the one app-owned file there is `assets/vendor/css/control.css`). - The admin portal has a light and a dark theme (`src/components/theme.rs`, switch in the navbar: auto / light / dark, stored in the `theme` cookie). Dark colours are overrides in `assets/css/dark.css`, which `AdminPortal` links with a `media` attribute — the public page and the login page never load it. When you add or restyle something in the admin portal, check it in both themes and add the dark override there; don't hard-code light colours (`bg-light`, `bg-white`, inline `color`) in admin views. - Charts are drawn on the server, so the chart server fns take a `dark` flag from `Theme::is_dark()`. -- Styles of the public page are in `assets/css/public.css`, scoped by its own classes (`.public-page` wraps the public route only, `.public-banner*`), so they don't leak into the admin portal. The customer's appearance settings reach it as CSS variables in a `style` attribute (`Colors::style()` in `src/backend/color.rs`, `BannerOptions::style()` in `src/backend/banner.rs`, `Appearance::banner_style()` in `src/backend/data.rs`) — never generate CSS files. Every value put into a `style` attribute must be validated first. +- Styles of the public page are in `assets/css/public.css`, scoped by its own classes (`.public-page` wraps the public route only, `.public-banner*`), so they don't leak into the admin portal. The customer's appearance settings reach it as CSS variables in a `style` attribute (`Colors::style()` in `src/backend/color.rs`, `BannerOptions::style()` in `src/backend/banner.rs`, `Appearance::banner_style()` in `src/backend/data.rs`) — and as classes on the wrapper for the layout options (`LayoutOptions::classes()` in `src/backend/layout.rs`); never generate CSS files. A new look option goes the same way: a column with a default equal to the current look, a validated field, a variable or class, a rule in `public.css`, a control in the appearance settings — the markup of the public form stays the same for all values. Every value put into a `style` attribute must be validated first. - The preview in the appearance settings is a `.public-page.public-preview` element inside the admin portal, styled by the same `public.css`. A rule there must set every property that `dark.css` sets for the same component, otherwise the preview changes with the admin theme. When the public form gets a new element, add it to the preview (`Preview` in `src/pages/appearance_settings.rs`) and check it with a dark palette. - Stylesheets and meta tags are declared in `src/components/header.rs`. diff --git a/CLAUDE.md b/CLAUDE.md index 07e4bc0..e20df4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,7 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[ - `src/backend/` — server functions per domain, plus `data.rs` (all DTOs/entities shared by server and client) and `auth_middleware.rs` (redirects unauthenticated `/admin*` page requests to `/login`). - `src/pages/` — page and dialog components; `src/components/` — reusable pieces (`DataForm`, `QuestionDialog`, `ModalDialog`, `ServerErr`, `ValidationErr`, `AdminPortal`, `Theme` for the light/dark theme of the admin portal). - `src/backend/banner.rs` — options of the public page banner (`BannerOptions`) and the checks of uploaded images; shared by server and browser. +- `src/backend/layout.rs` — layout options of the public booking form (`LayoutOptions`), which become classes on the page wrapper. - `src/backend/color.rs` — colours of the public page set by the customer (`Colors`), contrast checks and the ready-made palettes; shared by server and browser. - `src/locales/` — `trl()` and the translation catalogue. - `src/error.rs` — `AppError`; `src/config.rs` — `config.toml` structs (ssr only); `src/validator.rs` — client-side form validation helper. diff --git a/assets/css/public.css b/assets/css/public.css index 041a356..cc9d15d 100644 --- a/assets/css/public.css +++ b/assets/css/public.css @@ -161,6 +161,49 @@ color: var(--rz-body); } +/* Layout options: classes on .public-page from LayoutOptions::classes in src/backend/layout.rs. + The markup is the same for all of them. */ + +.public-page.rz-width-centered .public-form, +.public-page.rz-width-centered .public-banner-text { + max-width: 1100px; + margin-left: auto; + margin-right: auto; +} + +.public-page.rz-width-centered.rz-form-stacked .public-form, +.public-page.rz-width-centered.rz-form-stacked .public-banner-text { + max-width: 680px; +} + +.public-page.rz-form-stacked .public-form-columns > * { + flex: 0 0 100%; + max-width: 100%; +} + +.public-page.rz-slots-large .btn-check + .btn { + min-width: 5.5rem; + margin: 0 0.4rem 0.4rem 0; + padding: 0.8rem 1.1rem; + font-size: 1.0625rem; +} + +.public-page.rz-corners-square, +.public-page.rz-corners-square * { + border-radius: 0 !important; +} + +/* The preview is much narrower than a real window, so the centred column is shown in proportion */ +.public-page.public-preview.rz-width-centered .public-form, +.public-page.public-preview.rz-width-centered .public-banner-text { + max-width: 75%; +} + +.public-page.public-preview.rz-width-centered.rz-form-stacked .public-form, +.public-page.public-preview.rz-width-centered.rz-form-stacked .public-banner-text { + max-width: 55%; +} + /* Banner: the image (or a strip in the primary colour) with an optional logo and the title. The fallbacks are for the form embedded in the admin portal, outside of .public-page. */ @@ -225,12 +268,13 @@ font-size: x-large; } -.public-banner-focus { +/* Three classes: the mark stays round with square corners too */ +.public-page .public-banner .public-banner-focus { position: absolute; width: 14px; height: 14px; border: 2px solid #ffffff; - border-radius: 50%; + border-radius: 50% !important; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6); transform: translate(-50%, -50%); pointer-events: none; diff --git a/migrations/09_layout_options.sql b/migrations/09_layout_options.sql new file mode 100644 index 0000000..99bab28 --- /dev/null +++ b/migrations/09_layout_options.sql @@ -0,0 +1,5 @@ +-- Layout of the public booking form +ALTER TABLE appearance ADD layout_width VARCHAR(10) NOT NULL DEFAULT 'full'; +ALTER TABLE appearance ADD layout_arrangement VARCHAR(10) NOT NULL DEFAULT 'columns'; +ALTER TABLE appearance ADD layout_slots VARCHAR(10) NOT NULL DEFAULT 'normal'; +ALTER TABLE appearance ADD layout_corners VARCHAR(10) NOT NULL DEFAULT 'rounded'; diff --git a/src/backend/appearance.rs b/src/backend/appearance.rs index 563fa65..4481629 100644 --- a/src/backend/appearance.rs +++ b/src/backend/appearance.rs @@ -5,6 +5,7 @@ use validator::Validate; use crate::backend::banner::BannerOptions; use crate::backend::color::Colors; use crate::backend::data::{ApiResponse, Appearance}; +use crate::backend::layout::LayoutOptions; use crate::components::data_form::ForValidation; cfg_if! { if #[cfg(feature = "ssr")] { @@ -129,9 +130,9 @@ impl ForValidation for UpdateAppearance { } } -/// Saves the look of the public page: its colours and the options of the banner +/// Saves the look of the public page: its colours, the options of the banner and the layout of the form #[server] -pub async fn update_style(colors: Colors, banner: BannerOptions) -> Result, ServerFnError> { +pub async fn update_style(colors: Colors, banner: BannerOptions, layout: LayoutOptions) -> Result, ServerFnError> { use crate::perm_check; perm_check!(is_admin); @@ -143,12 +144,17 @@ pub async fn update_style(colors: Colors, banner: BannerOptions) -> Result Result, #[serde(default)] #[cfg_attr(feature = "ssr", sqlx(flatten))] - pub banner_options: BannerOptions + pub banner_options: BannerOptions, + #[serde(default)] + #[cfg_attr(feature = "ssr", sqlx(flatten))] + pub layout: LayoutOptions } impl Appearance { diff --git a/src/backend/layout.rs b/src/backend/layout.rs new file mode 100644 index 0000000..275ed7f --- /dev/null +++ b/src/backend/layout.rs @@ -0,0 +1,161 @@ +use serde::{Deserialize, Serialize}; +use validator::{Validate, ValidationError}; + +/// Values of the layout options: the first one of each list is the default and adds no class, +/// the others are paired with the class they put on the wrapper of the public page +/// (styled in `assets/css/public.css`). +const WIDTHS: [(&str, &str); 2] = [("full", ""), ("centered", "rz-width-centered")]; +const ARRANGEMENTS: [(&str, &str); 2] = [("columns", ""), ("stacked", "rz-form-stacked")]; +const SLOTS: [(&str, &str); 2] = [("normal", ""), ("large", "rz-slots-large")]; +const CORNERS: [(&str, &str); 2] = [("rounded", ""), ("square", "rz-corners-square")]; + +/// Layout of the public booking form, set by the customer in the appearance settings +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Validate)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct LayoutOptions { + /// Content over the full width of the window (`full`) or in a column in the middle (`centered`) + #[cfg_attr(feature = "ssr", sqlx(rename = "layout_width"))] + #[validate(custom(function = "validate_width", message = "Invalid layout"))] + pub width: String, + /// The two cards of the form side by side (`columns`) or below each other (`stacked`) + #[cfg_attr(feature = "ssr", sqlx(rename = "layout_arrangement"))] + #[validate(custom(function = "validate_arrangement", message = "Invalid layout"))] + pub arrangement: String, + /// Size of the time slot buttons: `normal` or `large` + #[cfg_attr(feature = "ssr", sqlx(rename = "layout_slots"))] + #[validate(custom(function = "validate_slots", message = "Invalid layout"))] + pub slots: String, + /// Corners of cards, buttons and inputs: `rounded` or `square` + #[cfg_attr(feature = "ssr", sqlx(rename = "layout_corners"))] + #[validate(custom(function = "validate_corners", message = "Invalid layout"))] + pub corners: String, +} + +impl Default for LayoutOptions { + fn default() -> Self { + LayoutOptions { + width: WIDTHS[0].0.to_string(), + arrangement: ARRANGEMENTS[0].0.to_string(), + slots: SLOTS[0].0.to_string(), + corners: CORNERS[0].0.to_string(), + } + } +} + +impl LayoutOptions { + /// Replaces values that are not valid with the default ones + pub fn sanitized(&self) -> Self { + let pick = |value: &str, options: &[(&str, &str)]| { + if is_option(value, options) { value.to_string() } else { options[0].0.to_string() } + }; + + LayoutOptions { + width: pick(&self.width, &WIDTHS), + arrangement: pick(&self.arrangement, &ARRANGEMENTS), + slots: pick(&self.slots, &SLOTS), + corners: pick(&self.corners, &CORNERS), + } + } + + /// Classes for the wrapper of the public page, separated by spaces. Only known classes + /// get there, whatever the values are. + pub fn classes(&self) -> String { + [ + class_of(&self.width, &WIDTHS), + class_of(&self.arrangement, &ARRANGEMENTS), + class_of(&self.slots, &SLOTS), + class_of(&self.corners, &CORNERS), + ].into_iter().filter(|class| !class.is_empty()).collect::>().join(" ") + } +} + +fn is_option(value: &str, options: &[(&str, &str)]) -> bool { + options.iter().any(|option| option.0 == value) +} + +fn class_of(value: &str, options: &[(&'static str, &'static str)]) -> &'static str { + options.iter().find(|option| option.0 == value).map(|option| option.1).unwrap_or_default() +} + +fn validate_option(value: &str, options: &[(&str, &str)]) -> Result<(), ValidationError> { + if is_option(value, options) { Ok(()) } else { Err(ValidationError::new("invalid option")) } +} + +fn validate_width(value: &str) -> Result<(), ValidationError> { + validate_option(value, &WIDTHS) +} + +fn validate_arrangement(value: &str) -> Result<(), ValidationError> { + validate_option(value, &ARRANGEMENTS) +} + +fn validate_slots(value: &str) -> Result<(), ValidationError> { + validate_option(value, &SLOTS) +} + +fn validate_corners(value: &str) -> Result<(), ValidationError> { + validate_option(value, &CORNERS) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn layout(width: &str, arrangement: &str, slots: &str, corners: &str) -> LayoutOptions { + LayoutOptions { + width: width.to_string(), + arrangement: arrangement.to_string(), + slots: slots.to_string(), + corners: corners.to_string(), + } + } + + #[test] + fn default_layout_is_valid_and_adds_no_class() { + let default = LayoutOptions::default(); + assert_eq!(default, layout("full", "columns", "normal", "rounded")); + assert!(default.validate().is_ok()); + assert_eq!(default, default.sanitized()); + assert_eq!(default.classes(), ""); + } + + #[test] + fn all_listed_values_are_valid() { + for width in ["full", "centered"] { + for arrangement in ["columns", "stacked"] { + for slots in ["normal", "large"] { + for corners in ["rounded", "square"] { + let options = layout(width, arrangement, slots, corners); + assert!(options.validate().is_ok(), "{:?}", options); + assert_eq!(options, options.sanitized()); + } + } + } + } + } + + #[test] + fn validation_rejects_other_values() { + assert!(layout("wide", "columns", "normal", "rounded").validate().is_err()); + assert!(layout("full", "rows", "normal", "rounded").validate().is_err()); + assert!(layout("full", "columns", "", "rounded").validate().is_err()); + assert!(layout("full", "columns", "normal", "rz-corners-square").validate().is_err()); + // A value of another option is not accepted either + assert!(layout("large", "columns", "normal", "rounded").validate().is_err()); + } + + #[test] + fn classes_of_changed_options() { + assert_eq!(layout("centered", "stacked", "large", "square").classes(), + "rz-width-centered rz-form-stacked rz-slots-large rz-corners-square"); + assert_eq!(layout("full", "stacked", "normal", "square").classes(), "rz-form-stacked rz-corners-square"); + assert_eq!(layout("centered", "columns", "normal", "rounded").classes(), "rz-width-centered"); + } + + #[test] + fn invalid_values_never_get_into_classes() { + let options = layout("\" onclick=\"x", "evil-class", "