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:
2026-02-20 18:46:38 +00:00
parent eb1a719fc3
commit 41dc839830
13 changed files with 385 additions and 1647 deletions
@@ -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>