feat(frontend): add new UI components and utilities

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-18 16:26:56 +01:00
co-authored by Sisyphus
parent 3ff14fdd5a
commit 165d3d6c38
7 changed files with 508 additions and 0 deletions
@@ -0,0 +1,87 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
let {
cardNumber = $bindable(''),
cardExpiry = $bindable(''),
cardCVC = $bindable(''),
saveCard = $bindable(false),
showSaveCard = false,
disabled = false
}: {
cardNumber?: string;
cardExpiry?: string;
cardCVC?: string;
saveCard?: boolean;
showSaveCard?: boolean;
disabled?: boolean;
} = $props();
function formatNumber(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 16);
const groups = digits.match(/.{1,4}/g);
return groups ? groups.join(' ') : digits;
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 4);
if (digits.length >= 3) {
return digits.substring(0, 2) + '/' + digits.substring(2);
}
return digits;
}
</script>
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="mb-4 text-sm font-medium text-gray-700">Card Details</h4>
<div class="space-y-4">
<div class="space-y-2">
<Label for="cardNumber">Card Number</Label>
<Input
id="cardNumber"
type="text"
inputmode="numeric"
value={cardNumber}
oninput={(e) => (cardNumber = formatNumber((e.target as HTMLInputElement).value))}
placeholder="1234 5678 9012 3456"
maxlength={19}
{disabled}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="cardExpiry">Expiry (MM/YY)</Label>
<Input
id="cardExpiry"
type="text"
inputmode="numeric"
value={cardExpiry}
oninput={(e) => (cardExpiry = formatExpiry((e.target as HTMLInputElement).value))}
placeholder="MM/YY"
maxlength={5}
{disabled}
/>
</div>
<div class="space-y-2">
<Label for="cardCVC">CVC</Label>
<Input
id="cardCVC"
type="text"
inputmode="numeric"
bind:value={cardCVC}
placeholder="123"
maxlength={4}
{disabled}
/>
</div>
</div>
{#if showSaveCard}
<div class="flex items-center gap-2">
<Checkbox id="saveCard" bind:checked={saveCard} disabled={disabled} />
<Label for="saveCard" class="text-sm font-normal">Save card for next time</Label>
</div>
{/if}
</div>
</div>
@@ -0,0 +1,16 @@
<script lang="ts">
let { status }: { status: string } = $props();
const variantMap: Record<string, { label: string; variantClass: string }> = {
draft: { label: 'Draft', variantClass: 'bg-gray-100 text-gray-800' },
active: { label: 'Active', variantClass: 'bg-emerald-100 text-emerald-800' },
completed: { label: 'Completed', variantClass: 'bg-blue-100 text-blue-800' },
cancelled: { label: 'Cancelled', variantClass: 'bg-red-100 text-red-800' }
};
const display = $derived(variantMap[status] ?? variantMap.draft);
</script>
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {display.variantClass}">
{display.label}
</span>
@@ -0,0 +1,68 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
trigger
}: {
trigger?: Snippet;
} = $props();
let open = $state(false);
function toggle() {
open = !open;
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('[data-policy-popover]')) {
open = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') open = false;
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
<span data-policy-popover class="relative inline-block">
<button
type="button"
onclick={toggle}
class="inline underline cursor-pointer text-xs hover:text-amber-700"
>
{#if trigger}
{@render trigger()}
{:else}
cancellation policy
{/if}
</button>
{#if open}
<div
class="absolute left-0 top-full z-50 mt-1 w-48 rounded-md border border-gray-200 bg-white p-2 shadow-lg"
>
<a
href="/cancellation-policy"
target="_blank"
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
onclick={() => (open = false)}
>
Open
</a>
<button
type="button"
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
onclick={() => {
open = false;
const win = window.open('/cancellation-policy?format=pdf', '_blank');
if (win) win.focus();
}}
>
Download PDF
</button>
</div>
{/if}
</span>
+11
View File
@@ -0,0 +1,11 @@
export const POLICY = {
FULL_REFUND_THRESHOLD_HOURS: 72,
PARTIAL_REFUND_THRESHOLD_HOURS: 24,
NO_SHOW_THRESHOLD_HOURS: 24,
DEPOSIT_ADVANCE_HOURS: 36,
LOYALTY_STAMP_REDEMPTION_COST: 10,
RESCHEDULE_BLOCK_HOURS_WITH_PAYMENTS: 72,
RESCHEDULE_BLOCK_HOURS_NO_PAYMENTS: 24,
PROTECTED_DEPOSIT_MAX_PCT: 0.50,
REQUIRED_DEPOSIT_PCT: 0.20,
} as const;
+28
View File
@@ -0,0 +1,28 @@
/**
* Sanitizes text for display in toast messages to prevent XSS.
* Escapes common HTML entities - defense-in-depth since svelte-sonner renders as text.
*/
export function sanitizeText(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;'
};
return text.replace(/[&<>"']/g, (ch) => map[ch] || ch);
}
/**
* Creates a sanitized error message from an API Response.
* Reads the response body as text and sanitizes it.
*/
export async function apiErrorText(response: Response): Promise<string> {
try {
const text = await response.text();
if (!text) return 'An error occurred';
return sanitizeText(text.slice(0, 200)); // limit length
} catch {
return 'An error occurred';
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Generate a UUID v4 string.
*
* Uses `window.crypto.getRandomValues()` when available (secure context),
* falls back to `Math.random()` for non-secure contexts (e.g. plain HTTP).
*
* Safe for all environments — does NOT rely on `crypto.randomUUID()`
* which requires a secure context (HTTPS).
*/
export function generateUUID(): string {
const array = new Uint8Array(16);
if (typeof window !== 'undefined' && window.crypto?.getRandomValues) {
window.crypto.getRandomValues(array);
} else {
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
}
// UUID v4 marker and variant
array[6] = (array[6] & 0x0f) | 0x40;
array[8] = (array[8] & 0x3f) | 0x80;
return [...array]
.map((b, i) => {
const hex = b.toString(16).padStart(2, '0');
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
return hex;
})
.join('');
}
@@ -0,0 +1,271 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
let format = $state('html');
let pdfNotice = $state(true);
onMount(() => {
format = $page.url.searchParams.get('format') || 'html';
if (format === 'pdf') {
// Strip ?format=pdf from the URL so a refresh doesn't re-trigger the print dialog.
const clean = window.location.pathname + window.location.hash;
history.replaceState(null, '', clean);
// Open the print dialog once the page is rendered.
// The notice element is removed before print so it won't appear in the PDF.
setTimeout(() => {
pdfNotice = false;
// Small delay so Svelte can remove the element before the print engine snapshots.
setTimeout(() => window.print(), 50);
}, 100);
}
});
</script>
<svelte:head>
<title>Booking, Deposit & Cancellation Policy</title>
<style>
@media print {
:global(nav),
:global(.no-print) {
display: none !important;
}
:global(body) {
padding-top: 0 !important;
}
}
</style>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8 text-gray-900">
<h1 class="mb-2 border-b border-gray-200 pb-4 text-2xl font-bold">
Booking, Deposit & Cancellation Policy
</h1>
<p class="mb-8 font-mono text-xs text-gray-500">Last updated: 18 June 2026</p>
{#if format === 'pdf' && pdfNotice}
<p class="mb-6 rounded border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600 italic">
Generating PDF… If the print dialog does not appear, use Ctrl+P / Cmd+P.
</p>
{/if}
<div class="space-y-8 text-sm leading-relaxed text-gray-700">
<!-- Section 1 -->
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">
1. Deposit Requirements & Booking Rules
</h2>
<p class="mb-3">
Where a deposit is required to secure your appointment, you must pay at least
<strong>20%</strong> of the total service cost before the 24-hour deadline prior to the
appointment. The 20% can be paid in a single payment or accumulated across multiple
payments — what matters is the total when the deadline passes. When deposit restrictions
are active on your account, appointments must be scheduled at least
<strong>36 hours in advance</strong>.
</p>
<p class="mb-3">
To maintain fairness and prevent scheduling abuse, accounts with outstanding deposit
requirements are limited to <strong>one (1) active booking</strong> at any given time. No additional
appointments can be scheduled until your current appointment is either completed, cancelled, or
officially lapsed.
</p>
</section>
<!-- Section 2 -->
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">
2. Unpaid Deposits & The "Pending Release" Window
</h2>
<p class="mb-3">
If a required deposit is not paid at least 24 hours before the appointment begins, the
booking is shifted into a <strong>"Pending Release"</strong> status. The slot becomes
vulnerable — if another customer books an overlapping time and pays, your original
booking is automatically evicted.
</p>
<p class="mb-3">
While in this status, your appointment is <strong>not guaranteed</strong>. The system will
make this time slot visible to other clients. If another user attempts to book an
overlapping time and completes their deposit payment first, your unpaid booking will be
immediately and automatically evicted.
</p>
<p class="mb-3">
If the slot has not yet been claimed by another client, paying your outstanding deposit will
instantly restore your booking to a fully confirmed status.
</p>
</section>
<!-- Section 3 -->
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Cancellation & Refund Tiers</h2>
<p class="mb-3">
We understand that plans can change. Eligibility for a refund depends strictly on the amount
of notice provided prior to your scheduled appointment time. These thresholds represent a
genuine pre-estimate of the operational costs and loss of business incurred by late
cancellations:
</p>
<div class="mt-4 divide-y divide-gray-200 rounded-md border border-gray-200">
<div class="bg-gray-50/50 p-4">
<p class="font-semibold text-gray-900">Notice of more than 72 hours</p>
<p class="mt-1 text-xs text-gray-600">
You are entitled to a full 100% refund of all deposits and advance payments made for the
booking.
</p>
</div>
<div class="p-4">
<p class="font-semibold text-gray-900">Notice between 24 and 72 hours</p>
<p class="mt-1 text-xs text-gray-600">
Any advance payments made up to 50% of the total booking value are treated as a
Protected Deposit. This Protected Deposit is retained to cover the short-notice vacancy,
while any balance paid above 50% will be fully refunded.
</p>
</div>
<div class="bg-gray-50/50 p-4">
<p class="font-semibold text-gray-900">Notice of less than 24 hours</p>
<p class="mt-1 text-xs text-gray-600">
All processed payments and deposits are entirely non-refundable and will be retained.
The cancellation will be logged as a missed appointment history strike.
</p>
</div>
</div>
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Refund Payment Method</h3>
<p class="mb-3 text-sm leading-relaxed text-gray-700">
Refunds are returned to the original payment method where possible:
</p>
<ul class="mb-4 list-disc space-y-2 pl-5 text-sm leading-relaxed text-gray-700">
<li>
<strong>Card payments</strong> (debit/credit card processed online or in-person): Refunded directly
back to the original card via Square. Processing times vary by card issuer (typically 310 working
days).
</li>
<li>
<strong>Gift card payments</strong>: Refunded back to the original gift card. The
gift card's remaining balance is incremented and is immediately available for use.
Expired gift cards are non-refundable.
</li>
<li>
<strong>Cash payments</strong>: Credited to your account balance, available for immediate
use against future bookings or services.
</li>
</ul>
<p class="mb-4 text-xs leading-relaxed text-gray-500 italic">
If a card refund cannot be processed (e.g. the card is expired or the Square payment
reference is unavailable), the refund amount will be credited to your account balance as a
fallback, so you are never left out of pocket.
</p>
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Guest / Walk-In Bookings</h3>
<p class="mb-3 text-sm leading-relaxed text-gray-700">
Walk-in customers who book without creating an account are issued a <strong
>guest account</strong
> to hold their booking and payment records. The same cancellation and refund tiers above apply
to guest bookings. Refunds are returned as follows:
</p>
<ul class="mb-4 list-disc space-y-2 pl-5 text-sm leading-relaxed text-gray-700">
<li>
<strong>Card payments</strong>: Refunded back to the original card via Square (same as
registered users).
</li>
<li>
<strong>Gift card payments</strong>: Refunded back to the original gift card. The
gift card's remaining balance is incremented. Expired gift cards are non-refundable.
</li>
<li>
<strong>Cash payments</strong>: Refunded in person at the salon. Please bring your receipt
and an admin will process your cash refund at the till.
</li>
</ul>
<p class="mb-3 text-xs leading-relaxed text-gray-500 italic">
Guest accounts do not hold rolling balances. All refunds for walk-in bookings are returned
via the original payment method or processed manually by salon staff.
</p>
</section>
<!-- Section 4 -->
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">4. Missed Appointments (No-Shows)</h2>
<p class="mb-3">
Failing to attend a confirmed appointment without notifying us in advance constitutes a
"No-Show". Cancelling a pending or deposit-lapsed booking within 24 hours does
<strong>not</strong> count as a no-show — only confirmed bookings (where the slot was
secured with a payment) can incur no-show strikes.
</p>
<p class="mb-3">
If your account accumulates <strong
>two (2) No-Show strikes within a rolling 6-month period</strong
>, our system will automatically restrict your account privileges, making upfront deposits
mandatory for future booking attempts (3 deposits required before deposits are automatically
cleared). No-show strikes older than 6 months are automatically excluded from the count.
</p>
<p class="mb-3">
Each completed booking with a payment reduces the required deposit count by one. Once the
count reaches zero, all prior no-show records within the 6-month window are forgiven and
your account returns to normal — no upfront deposits required — until a new no-show occurs.
The salon can also forgive individual no-shows at management's discretion, which
immediately removes them from the count.
</p>
</section>
<!-- Section 5 -->
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">
5. Statutory Rights & Exceptional Circumstances
</h2>
<p class="mb-3">
This policy is strictly aligned with the <strong>Consumer Rights Act 2015</strong>. Nothing
in these terms limits your statutory right to receive services carried out with reasonable
care and skill, or your right to a full refund if we are forced to cancel your appointment
due to our own scheduling conflicts.
</p>
<p class="mb-3">
Please note that in accordance with UK statutory exclusions for distance contracts, the
standard 14-day statutory cancellation "cooling-off" period under the Consumer Contracts
Regulations 2013 does not apply to online bookings scheduled for a specific date or time.
</p>
<p class="mb-3">
We recognize that genuine emergencies, sudden severe illness, or bereavement can occur. Our
management team retains complete administrative system access to waive cancellation fees,
refund deposits, or clear no-show history strikes on a case-by-case basis under exceptional
circumstances.
</p>
</section>
<!-- Section 6 -->
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">6. Policy Amendments and Updates</h2>
<p class="mb-3">
We reserve the right to amend, update, or modify this Booking, Deposit & Cancellation Policy
at any time to reflect changes in our business operations, system features, or legal
obligations under UK and Scottish law.
</p>
<p class="mb-3">
Any updates will be published directly to this page, and the "Last updated" date at the top
will change accordingly. For active bookings scheduled prior to an amendment, the terms in
place at the exact time your booking was created will apply. Continued use of our booking
system after a policy revision implies formal agreement to the updated terms.
</p>
</section>
<!-- Section 7 -->
<section class="border-t border-gray-200 pt-6">
<h2 class="mb-2 text-base font-semibold text-gray-900">7. Governing Law</h2>
<p class="mb-4">
These terms and conditions are governed by and construed in accordance with the laws of
Scotland. Any disputes or legal claims arising from these provisions shall be subject to the
exclusive jurisdiction of the Scottish courts.
</p>
<p class="text-xs text-gray-500">
If you need to request an adjustment due to exceptional circumstances or have questions
regarding your upcoming appointments, please use our official <a
href="/contact"
class="font-medium text-blue-600 underline hover:text-blue-800">Contact Channels</a
> to get in touch.
</p>
</section>
</div>
</div>