Last updated {{updated}}
These terms apply when you book or receive a treatment from {{name}} ("we", "us"), use our online booking or loyalty pass, or visit our studio at {{address}}. By booking, you agree to them.
Lash retention varies with your natural lash cycle, skin type and aftercare. Please follow the aftercare advice you're given. We can't guarantee how long extensions will last, but if something isn't right within a few days, contact us and we'll take a look.
We only keep photos of your lashes in your client record if you agree. We will never post them publicly without asking you separately.
We take reasonable care with every treatment. Nothing in these terms limits any rights you have by law that can't be limited. Otherwise, we aren't responsible for reactions or problems caused by information you didn't tell us, or by not following aftercare advice.
We may update these terms; the latest version is always on this site. These terms are governed by the laws of {{law}}. Questions? Contact us at {{email}} or {{phone}}.
Last updated {{updated}}
{{name}} ("we", "us") respects your privacy. This policy explains what information we collect when you book, use your loyalty pass, or visit us, and how we look after it. Contact: {{email}} · {{phone}} · {{address}}.
We do not sell your information, use it for advertising, or send you marketing unless you ask us to.
We keep your client record {{retention}}, then delete it. Booking requests are kept for the same period.
You can ask to see the information we hold about you, correct it, delete it, or withdraw your consent for health information or photos at any time, by contacting {{email}}. Withdrawing consent doesn't affect treatments already given, but we may not be able to treat you safely without key health information. Depending on where you live, you may also have the right to complain to your data protection authority.
This site doesn't use advertising or tracking cookies. The stylist's login is kept only while the page is open.
Data is sent over encrypted connections (HTTPS) and protected by database rules that only allow signed-in stylists to read client records. The stylist portal locks automatically after 15 minutes without use.
We'll post any updates to this policy here, with a new "last updated" date.
One app, two roles. The same front end serves the public Client Pass and the PIN-locked Stylist Portal; a role gate decides what is rendered and what data is reachable.
In this file the data layer is the browser's local storage (one device). In production the same model maps 1:1 onto Postgres tables (Supabase), below.
| Entity | Key fields | Notes |
|---|---|---|
| Clients | id, pass_code, first_name, last_name, phone (unique), email, dob, allergies, active_count (0–10), created_at | active_count is the current loyalty cycle; lifetime visits are counted from Appointments, so resets never lose history. |
| LashSpecs | client_id (1:1), style, curl, length_map, diameter, adhesive_notes | Style ∈ Classic, Hybrid, Volume, Mega Volume. Curl ∈ B, C, CC, D, DD, L, L+, M. |
| Appointments | id, client_id, visit_date, service, is_free, retention_notes, photo_path, source (scan | manual | redeem) | Every visit, paid or free. Permanent log. |
| Bookings | id, ref, client_id, name, phone, email, dob, allergies, service, req_date, req_time, style_pref, notes, first_visit, photo/health consent, terms_accepted_at, status, stylist_message | Public booking requests. Status: pending → approved / declined → completed or cancelled. Approving creates the client record for first-timers. |
| Redemptions | id, client_id, appointment_id, service, redeemed_at, redeemed_by | Audit trail of every free service. |
| Auth / Staff | user_id, display_name, role; (local: salt + pin_hash) | Who can unlock the Stylist Portal. |
This is the exact script to run in Supabase (also provided as yorkshire-beauty-setup.sql).
-- =====================================================================
-- Yorkshire Beauty: database setup
-- Supabase → SQL Editor → New query → paste ALL of this → Run.
-- Safe to run more than once.
-- =====================================================================
create extension if not exists pgcrypto;
-- Phone numbers are matched in a normalised form so "07700 900123",
-- "+44 7700 900123" and "(215) 555-0199" / "+1 215 555 0199" all match.
create or replace function public.norm_phone(p text) returns text
language sql immutable as $$
select case
when d = '' then ''
when length(d) = 11 and d like '1%' then '0' || substr(d, 2)
when d like '44%' then '0' || substr(d, 3)
when d like '0%' then d
else '0' || d
end
from (select regexp_replace(coalesce(p, ''), '\D', '', 'g') as d) x
$$;
-- ---------- Tables ----------
create table if not exists public.staff (
user_id uuid primary key references auth.users on delete cascade,
display_name text,
created_at timestamptz not null default now()
);
create table if not exists public.clients (
id uuid primary key default gen_random_uuid(),
pass_code text unique not null default upper(encode(gen_random_bytes(6), 'hex')),
first_name text not null,
last_name text not null default '',
phone text not null,
email text not null default '',
dob date,
allergies text not null default '',
active_count int not null default 0 check (active_count between 0 and 10),
created_at timestamptz not null default now()
);
create unique index if not exists clients_phone_norm on public.clients (public.norm_phone(phone));
create table if not exists public.lash_specs (
client_id uuid primary key references public.clients on delete cascade,
style text not null default 'Classic',
curl text not null default 'C',
length_map text not null default '',
diameter text not null default '0.15',
adhesive_notes text not null default '',
updated_at timestamptz not null default now()
);
create table if not exists public.appointments (
id uuid primary key default gen_random_uuid(),
client_id uuid not null references public.clients on delete cascade,
visit_date date not null default current_date,
service text not null,
is_free boolean not null default false,
retention_notes text not null default '',
photo text,
source text not null default 'manual',
created_at timestamptz not null default now()
);
create index if not exists appointments_client on public.appointments (client_id);
create table if not exists public.redemptions (
id uuid primary key default gen_random_uuid(),
client_id uuid not null references public.clients on delete cascade,
appointment_id uuid references public.appointments on delete set null,
service text not null,
redeemed_on date not null default current_date,
redeemed_by uuid references auth.users,
created_at timestamptz not null default now()
);
create table if not exists public.bookings (
id uuid primary key default gen_random_uuid(),
ref text unique not null default upper(encode(gen_random_bytes(3), 'hex')),
client_id uuid references public.clients on delete set null,
first_name text not null,
last_name text not null default '',
phone text not null,
email text not null default '',
dob date,
allergies text not null default '',
service text not null,
req_date date not null,
req_time text not null,
style_pref text not null default '',
notes text not null default '',
first_visit boolean not null default false,
photo_consent boolean not null default false,
health_consent boolean not null default false,
terms_accepted_at timestamptz not null default now(),
status text not null default 'pending'
check (status in ('pending','approved','declined','cancelled','completed')),
stylist_message text not null default '',
appointment_id uuid references public.appointments on delete set null,
created_at timestamptz not null default now(),
decided_at timestamptz
);
create index if not exists bookings_status on public.bookings (status, req_date);
create index if not exists bookings_client on public.bookings (client_id);
-- Every new client automatically gets a lash-spec row
create or replace function public.tg_client_specs() returns trigger
language plpgsql as $$
begin
insert into public.lash_specs (client_id) values (new.id) on conflict do nothing;
return new;
end $$;
drop trigger if exists client_specs on public.clients;
create trigger client_specs after insert on public.clients
for each row execute function public.tg_client_specs();
-- ---------- Security: only stylists can touch client records ----------
create or replace function public.is_staff() returns boolean
language sql stable security definer set search_path = public as $$
select exists (select 1 from staff where user_id = auth.uid())
$$;
alter table public.staff enable row level security;
alter table public.clients enable row level security;
alter table public.lash_specs enable row level security;
alter table public.appointments enable row level security;
alter table public.redemptions enable row level security;
drop policy if exists staff_self on public.staff;
create policy staff_self on public.staff for select to authenticated using (user_id = auth.uid());
drop policy if exists staff_all on public.clients;
create policy staff_all on public.clients for all to authenticated using (public.is_staff()) with check (public.is_staff());
drop policy if exists staff_all on public.lash_specs;
create policy staff_all on public.lash_specs for all to authenticated using (public.is_staff()) with check (public.is_staff());
drop policy if exists staff_all on public.appointments;
create policy staff_all on public.appointments for all to authenticated using (public.is_staff()) with check (public.is_staff());
drop policy if exists staff_all on public.redemptions;
create policy staff_all on public.redemptions for all to authenticated using (public.is_staff()) with check (public.is_staff());
alter table public.bookings enable row level security;
drop policy if exists staff_all on public.bookings;
create policy staff_all on public.bookings for all to authenticated using (public.is_staff()) with check (public.is_staff());
-- ---------- Check-in: +1 visit, tells the app when the 10th is reached ----------
create or replace function public.log_visit(
p_client uuid, p_service text, p_date date default null,
p_notes text default '', p_photo text default null, p_source text default 'manual')
returns json language plpgsql security definer set search_path = public as $$
declare a appointments; cnt int; due boolean;
begin
if not is_staff() then raise exception 'not authorised'; end if;
select active_count into cnt from clients where id = p_client for update;
if not found then raise exception 'client not found'; end if;
insert into appointments (client_id, visit_date, service, retention_notes, photo, source)
values (p_client, coalesce(p_date, current_date), p_service, coalesce(p_notes, ''), p_photo, coalesce(p_source, 'manual'))
returning * into a;
if cnt >= 10 then
due := true;
else
cnt := cnt + 1;
update clients set active_count = cnt where id = p_client;
due := (cnt = 10);
end if;
return json_build_object('appointment', row_to_json(a), 'active_count', cnt, 'reward_due', due);
end $$;
-- ---------- Redeem: mark visit free, log it, reset card to 0/10 ----------
create or replace function public.redeem_reward(p_client uuid, p_service text, p_appt uuid default null)
returns json language plpgsql security definer set search_path = public as $$
declare a appointments; r redemptions;
begin
if not is_staff() then raise exception 'not authorised'; end if;
if p_appt is null then
insert into appointments (client_id, service, is_free, source)
values (p_client, p_service, true, 'redeem') returning * into a;
else
update appointments set is_free = true, service = p_service
where id = p_appt and client_id = p_client returning * into a;
end if;
insert into redemptions (client_id, appointment_id, service, redeemed_by)
values (p_client, a.id, p_service, auth.uid()) returning * into r;
update clients set active_count = 0 where id = p_client;
return json_build_object('appointment', row_to_json(a), 'redemption', row_to_json(r));
end $$;
-- ---------- The ONLY thing the public client pass can read ----------
-- First name, pass code, loyalty count, usual set and visit dates.
-- Never allergies, glue notes, email, DOB, photos or other clients.
create or replace function public.get_client_pass(p_phone text default null, p_code text default null)
returns json language sql stable security definer set search_path = public as $$
select json_build_object(
'first_name', c.first_name, 'code', c.pass_code, 'active_count', c.active_count,
'style', s.style, 'curl', s.curl, 'length_map', s.length_map, 'diameter', s.diameter,
'history', coalesce((
select json_agg(json_build_object('date', a.visit_date, 'service', a.service, 'free', a.is_free)
order by a.visit_date desc, a.created_at desc)
from appointments a where a.client_id = c.id), '[]'::json),
'bookings', coalesce((
select json_agg(json_build_object('ref', b.ref, 'service', b.service, 'date', b.req_date, 'time', b.req_time,
'status', b.status, 'message', b.stylist_message)
order by b.req_date, b.req_time)
from bookings b
where (b.client_id = c.id or norm_phone(b.phone) = norm_phone(c.phone))
and b.req_date >= current_date - 1 and b.status in ('pending','approved','declined')), '[]'::json))
from clients c left join lash_specs s on s.client_id = c.id
where (p_phone is not null and norm_phone(p_phone) <> '' and norm_phone(c.phone) = norm_phone(p_phone))
or (p_code is not null and c.pass_code = upper(p_code))
limit 1
$$;
revoke all on function public.log_visit(uuid, text, date, text, text, text) from public, anon;
revoke all on function public.redeem_reward(uuid, text, uuid) from public, anon;
grant execute on function public.log_visit(uuid, text, date, text, text, text) to authenticated;
grant execute on function public.redeem_reward(uuid, text, uuid) to authenticated;
grant execute on function public.get_client_pass(text, text) to anon, authenticated;
-- ---------- First login becomes the stylist automatically ----------
-- The first account created from the app's "Create stylist account"
-- screen is made the stylist. Accounts created after that get no access.
create or replace function public.tg_first_staff() returns trigger
language plpgsql security definer set search_path = public as $$
begin
if not exists (select 1 from public.staff) then
insert into public.staff (user_id) values (new.id);
end if;
return new;
end $$;
drop trigger if exists first_staff on auth.users;
create trigger first_staff after insert on auth.users
for each row execute function public.tg_first_staff();
-- Lets the login screen know whether to offer "Create stylist account"
create or replace function public.has_stylist() returns boolean
language sql stable security definer set search_path = public as $$
select exists (select 1 from staff)
$$;
grant execute on function public.has_stylist() to anon, authenticated;
-- ---------- Online booking ----------
-- Public: send a booking request. The stylist approves or declines it.
create or replace function public.request_booking(p jsonb)
returns json language plpgsql security definer set search_path = public as $$
declare b bookings; d date; cid uuid; pending int;
begin
if coalesce(trim(p->>'first_name'), '') = '' or norm_phone(p->>'phone') = '' then
raise exception 'Please enter your name and phone number.'; end if;
if coalesce((p->>'terms')::boolean, false) is not true then
raise exception 'Please accept the Terms and Privacy Policy.'; end if;
if coalesce(trim(p->>'allergies'), '') <> '' and coalesce((p->>'health_consent')::boolean, false) is not true then
raise exception 'Please consent to us storing your health information, or leave that field empty.'; end if;
d := (p->>'date')::date;
if d is null or d < current_date or d > current_date + 180 then
raise exception 'Please choose a date within the next 6 months.'; end if;
if coalesce(p->>'time', '') !~ '^[0-2][0-9]:[0-5][0-9]$' then raise exception 'Please choose a time.'; end if;
if coalesce(trim(p->>'service'), '') = '' then raise exception 'Please choose a service.'; end if;
select count(*) into pending from bookings
where norm_phone(phone) = norm_phone(p->>'phone') and status = 'pending';
if pending >= 3 then
raise exception 'You already have 3 requests waiting. We will be in touch soon.'; end if;
select id into cid from clients where norm_phone(phone) = norm_phone(p->>'phone');
insert into bookings (client_id, first_name, last_name, phone, email, dob, allergies, service, req_date, req_time,
style_pref, notes, first_visit, photo_consent, health_consent)
values (cid, left(trim(p->>'first_name'), 60), left(coalesce(trim(p->>'last_name'), ''), 60), left(trim(p->>'phone'), 30),
left(coalesce(trim(p->>'email'), ''), 120), nullif(p->>'dob', '')::date, left(coalesce(trim(p->>'allergies'), ''), 1000),
left(trim(p->>'service'), 60), d, p->>'time', left(coalesce(trim(p->>'style_pref'), ''), 300),
left(coalesce(trim(p->>'notes'), ''), 1000), coalesce((p->>'first_visit')::boolean, false),
coalesce((p->>'photo_consent')::boolean, false), coalesce((p->>'health_consent')::boolean, false))
returning * into b;
return json_build_object('ref', b.ref, 'date', b.req_date, 'time', b.req_time, 'service', b.service, 'status', b.status);
end $$;
-- Public: check a request using reference + phone (both must match)
create or replace function public.booking_status(p_ref text, p_phone text)
returns json language sql stable security definer set search_path = public as $$
select json_build_object('ref', ref, 'first_name', first_name, 'service', service, 'date', req_date,
'time', req_time, 'status', status, 'message', stylist_message)
from bookings
where ref = upper(trim(p_ref)) and norm_phone(phone) = norm_phone(p_phone) and norm_phone(p_phone) <> ''
limit 1
$$;
-- Stylist: approve (creates the client record for first-timers)
create or replace function public.approve_booking(p_id uuid, p_date date, p_time text, p_message text default '')
returns json language plpgsql security definer set search_path = public as $$
declare b bookings; cid uuid;
begin
if not is_staff() then raise exception 'not authorised'; end if;
select * into b from bookings where id = p_id for update;
if not found then raise exception 'booking not found'; end if;
cid := b.client_id;
if cid is null then
select id into cid from clients where norm_phone(phone) = norm_phone(b.phone);
end if;
if cid is null then
insert into clients (first_name, last_name, phone, email, dob, allergies)
values (b.first_name, b.last_name, b.phone, b.email, b.dob, b.allergies) returning id into cid;
if b.style_pref <> '' then
update lash_specs set length_map = b.style_pref where client_id = cid;
end if;
end if;
update bookings set status = 'approved', client_id = cid, req_date = coalesce(p_date, req_date),
req_time = coalesce(nullif(p_time, ''), req_time), stylist_message = coalesce(p_message, ''), decided_at = now()
where id = p_id returning * into b;
return json_build_object('booking', row_to_json(b),
'client', (select row_to_json(c) from clients c where c.id = cid),
'specs', (select row_to_json(s) from lash_specs s where s.client_id = cid));
end $$;
-- Stylist: client arrived → log the visit (+1 loyalty) and close the booking
create or replace function public.complete_booking(p_id uuid)
returns json language plpgsql security definer set search_path = public as $$
declare b bookings; r json;
begin
if not is_staff() then raise exception 'not authorised'; end if;
select * into b from bookings where id = p_id for update;
if not found or b.client_id is null then raise exception 'Approve the booking first.'; end if;
if b.status = 'completed' then raise exception 'This booking is already completed.'; end if;
r := log_visit(b.client_id, b.service, current_date, '', null, 'booking');
update bookings set status = 'completed', appointment_id = (r->'appointment'->>'id')::uuid where id = p_id
returning * into b;
return json_build_object('visit', r, 'booking', row_to_json(b));
end $$;
revoke all on function public.approve_booking(uuid, date, text, text) from public, anon;
revoke all on function public.complete_booking(uuid) from public, anon;
grant execute on function public.approve_booking(uuid, date, text, text) to authenticated;
grant execute on function public.complete_booking(uuid) to authenticated;
grant execute on function public.request_booking(jsonb) to anon, authenticated;
grant execute on function public.booking_status(text, text) to anon, authenticated;
-- Done. Click Run, then go back to the chat.
-- (Adding a second stylist later: they create an account, then run
-- insert into public.staff (user_id) select id from auth.users where email = 'their@email.com';)
┌──────────────────────────────────────┐
│ (YB) Yorkshire Beauty [🔒 Stylist] │ ← login button, top-right
├──────────────────────────────────────┤
│ (YB logo) │
│ Your loyalty pass │
│ [ Phone number ] [Find pass] │
└──────────────────────────────────────┘
↓ match found
┌──────────────────────────────────────┐
│ ▓▓ PASS CARD (heather gradient) ▓▓▓▓ │
│ (YB) Yorkshire Beauty │
│ Amelia │
│ ┌──────────┐ │
│ │ QR (YB) │ ← logo in QR │
│ └──────────┘ │
│ A1B2 C3D4 E5F6 │
├──────────────────────────────────────┤
│ ● ● ● ● ● ● ● ○ ○ ★ 7 / 10 │
│ [★ Free Service Earned!] (at 10/10) │
├──────────────────────────────────────┤
│ Your usual set (read-only) │
│ [Hybrid] [CC curl] [9–13mm] [0.07] │
├──────────────────────────────────────┤
│ Service history │
│ 12 Sep 2026 Refill │
│ 22 Aug 2026 Full Set [Free] │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐ │ (YB) Yorkshire Beauty 14:52 [Lock] │ ← auto-lock countdown │ [ Scan ][ Clients ][ Settings ][ Blueprint ] ├──────────────────────────────────────┤ │ SCAN: camera viewfinder │ │ [Start camera] [Scan from photo] │ │ [ ID or phone ] [Check in] │ │ → on scan: open profile, +1 visit │ │ → if 10/10: ┌────────────────────┐ │ │ │ 10th Visit Reached!│ │ │ │ ( Refill ) (Full) │ │ │ │ [Redeem Reward] │ │ │ └────────────────────┘ │ ├──────────────────────────────────────┤ │ CLIENT PROFILE │ │ ⚠ Allergy banner (if any) │ │ Loyalty 7/10 · lifetime · ± correct │ │ Personal: name/phone/email/DOB/allergy│ │ Lash specs: style/curl/map/dia/glue │ │ Visit log: photo · service · notes │ └──────────────────────────────────────┘
| Option | Best for | Trade-offs |
|---|---|---|
| This single HTML file | Trying it today on one tablet/phone at the studio | Data lives on that one device only; photos limited by browser storage (~5 MB ≈ 30–40 photos). Export backups regularly. |
| Supabase + this front end ★ | Real business use across devices | Postgres, auth, row-level security and private photo storage in one; generous free tier. Needs a little SQL setup. |
| React (Vite) + Firebase | Developers who prefer NoSQL | Security rules are capable but harder to reason about for relational data like visits ↔ redemptions. |
| Glide | No-code, spreadsheet-backed | Fast to build; QR scanning and custom reset logic are possible but per-user pricing climbs. |
| Bubble | No-code with complex workflows | Powerful but heavier, slower page loads, steeper learning curve. |
yorkshire-beauty-setup.sql) → Run.SUPABASE_URL and SUPABASE_ANON_KEY at the top of the script, or send them to Claude to do it.index.html and drag it onto app.netlify.com/drop. You get a free HTTPS link (the camera scanner needs HTTPS).1234. Change it in Settings on day one.A PIN in a browser-only app is a privacy screen, not a vault. Anyone with the device and developer tools could read local storage. Keep the device with you, or move to Supabase for real protection.
All of the protections below are already built into the setup script in section 1: row-level security on every table, the is_staff() check, and get_client_pass as the only function a public visitor can call.
get_client_pass. Every table read/write requires a staff session, enforced by the database.