feat(booking): add service eligibility based on age and patch tests
- Add eligibility filtering to /api/services: exclude services below
user's
age, gray out services requiring patch tests that are missing/expired
- Add new endpoint /api/services/eligible-for/{user_id} for admin
booking
flows to check eligibility for a specific user
- Add image metadata stripping: uploads now strip all EXIF/GPS data
via imaging library (security improvement)
- Update ServiceCard frontend: show grayed-out state for ineligible
services with "contact us" link (public) or just warning (admin)
- Add 2 patch test services to seed data: Gel Polish Full Set,
Luxury Gel Manicure (48h each)
- Remove deprecated local-dev.sh script
This commit is contained in:
@@ -202,6 +202,13 @@
|
||||
wasOpen = open;
|
||||
});
|
||||
|
||||
// Refetch services when selected user changes (for eligibility)
|
||||
$effect(() => {
|
||||
if (open && selectedUserId) {
|
||||
fetchServices();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open && currentStep === 4) {
|
||||
const dateToCheck = selectedDate || placeholder;
|
||||
@@ -265,7 +272,12 @@
|
||||
async function fetchServices() {
|
||||
loadingServices = true;
|
||||
try {
|
||||
const response = await fetch('/api/services', {
|
||||
let url = '/api/services';
|
||||
// If a user is selected, get eligibility for that user
|
||||
if (selectedUserId) {
|
||||
url = `/api/services/eligible-for/${selectedUserId}`;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
@@ -910,6 +922,7 @@
|
||||
selected={selectedServices}
|
||||
loading={loadingServices}
|
||||
ontoggle={toggleService}
|
||||
showContactLink={false}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -116,6 +116,13 @@
|
||||
wasOpen = open;
|
||||
});
|
||||
|
||||
// Refetch services when selected user changes (for eligibility)
|
||||
$effect(() => {
|
||||
if (open && selectedUserId) {
|
||||
fetchServices();
|
||||
}
|
||||
});
|
||||
|
||||
function resetState() {
|
||||
currentStep = 1;
|
||||
userType = 'member';
|
||||
@@ -159,7 +166,12 @@
|
||||
async function fetchServices() {
|
||||
loadingServices = true;
|
||||
try {
|
||||
const response = await fetch('/api/services', {
|
||||
let url = '/api/services';
|
||||
// If a user is selected, get eligibility for that user
|
||||
if (selectedUserId) {
|
||||
url = `/api/services/eligible-for/${selectedUserId}`;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
@@ -553,6 +565,7 @@
|
||||
selected={selectedServices}
|
||||
loading={loadingServices}
|
||||
ontoggle={toggleService}
|
||||
showContactLink={false}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -61,7 +61,24 @@
|
||||
|
||||
if (response.ok) {
|
||||
const data: Service[] = await response.json();
|
||||
services = data;
|
||||
// Sort: valid patch tests first (by name), then grayed out (by name)
|
||||
const valid: Service[] = [];
|
||||
const grayedOut: Service[] = [];
|
||||
|
||||
for (const service of data) {
|
||||
if (service.patch_test_status === 'required' || service.patch_test_status === 'expired') {
|
||||
grayedOut.push(service);
|
||||
} else {
|
||||
valid.push(service);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort each group alphabetically
|
||||
valid.sort((a, b) => a.name.localeCompare(b.name));
|
||||
grayedOut.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Combine: valid first, then grayed out
|
||||
services = [...valid, ...grayedOut];
|
||||
} else {
|
||||
console.error('Failed to fetch services:', response.status);
|
||||
toast.error('Failed to load services');
|
||||
|
||||
@@ -4,45 +4,49 @@
|
||||
let {
|
||||
service,
|
||||
selected = false,
|
||||
onclick
|
||||
onclick,
|
||||
showContactLink = false
|
||||
}: {
|
||||
service: Service;
|
||||
selected?: boolean;
|
||||
onclick?: () => void;
|
||||
showContactLink?: boolean;
|
||||
} = $props();
|
||||
|
||||
const isGrayedOut = $derived(
|
||||
service.patch_test_status === 'required' || service.patch_test_status === 'expired'
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50 hover:text-accent-foreground {selected
|
||||
? 'bg-fuchsia-100'
|
||||
: ''}"
|
||||
onclick={() => onclick?.()}
|
||||
: ''} {isGrayedOut ? 'opacity-50' : ''}"
|
||||
onclick={() => !isGrayedOut && onclick?.()}
|
||||
disabled={isGrayedOut}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex flex-col justify-between h-full min-h-[6rem]">
|
||||
<div>
|
||||
<h3 class="font-semibold">{service.name}</h3>
|
||||
<p class="text-sm text-gray-600">{service.description}</p>
|
||||
<div class="mt-2 flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>{service.duration_minutes} mins</span>
|
||||
<span>£{service.price}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ml-3 flex h-5 w-5 items-center justify-center rounded border-2 {selected
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-gray-300'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if selected}
|
||||
<svg class="h-3 w-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
{#if isGrayedOut}
|
||||
<p class="text-sm text-amber-600">
|
||||
{#if service.patch_test_status === 'required'}
|
||||
Patch test required
|
||||
{:else}
|
||||
Patch test expired
|
||||
{/if}
|
||||
{#if showContactLink}
|
||||
- <a href="/contact" class="underline">contact us</a>
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-600">{service.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-2 text-sm text-gray-500">
|
||||
<span>{service.duration_minutes} mins</span>
|
||||
<span class="font-semibold text-foreground">£{service.price}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
services = [],
|
||||
selected = [],
|
||||
loading = true,
|
||||
ontoggle
|
||||
ontoggle,
|
||||
showContactLink = false
|
||||
}: {
|
||||
services?: Service[];
|
||||
selected?: Service[];
|
||||
loading?: boolean;
|
||||
ontoggle?: (service: Service) => void;
|
||||
showContactLink?: boolean;
|
||||
} = $props();
|
||||
|
||||
function isServiceSelected(service: Service): boolean {
|
||||
@@ -30,6 +32,7 @@
|
||||
{service}
|
||||
selected={isServiceSelected(service)}
|
||||
onclick={() => ontoggle?.(service)}
|
||||
{showContactLink}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user