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