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:
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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 3–10 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>
|
||||
Reference in New Issue
Block a user