Base app architecture:
Route groups for customer, staff and owner.
* Reusable design system with around 20 UI components.
* Complete Prisma schema.
* Authentication with email/password and Google.
* Role and permission model.
* Protected routes.
* Clean separation between features, services, components and shared utilities.
418 lines
21 KiB
HTML
Executable File
418 lines
21 KiB
HTML
Executable File
<html lang="en">
|
||
|
||
<head>
|
||
<script>
|
||
window.FontAwesomeConfig = {
|
||
autoReplaceSvg: 'nest'
|
||
};
|
||
</script>
|
||
<script
|
||
src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js"
|
||
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport"
|
||
content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<title>Loyalty Configuration</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link
|
||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap"
|
||
rel="stylesheet">
|
||
<link rel="stylesheet"
|
||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||
<script>
|
||
tailwind.config = {
|
||
theme:
|
||
{
|
||
extend:
|
||
{
|
||
colors:
|
||
{
|
||
ink: '#111b15',
|
||
stone: '#f4f6f4',
|
||
surface: '#ffffff',
|
||
muted: '#6c7870',
|
||
line: '#e1e7e2',
|
||
accent: '#10b981',
|
||
},
|
||
fontFamily:
|
||
{
|
||
sans: ['Inter', 'sans-serif']
|
||
},
|
||
borderRadius:
|
||
{
|
||
jumbo: '1.5rem',
|
||
tile: '1rem'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
<style>
|
||
::-webkit-scrollbar
|
||
{
|
||
display: none;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body class="bg-stone text-ink font-sans pb-40">
|
||
<!-- Header -->
|
||
<header id="header"
|
||
class="bg-white border-b border-line px-6 pt-12 pb-5 sticky top-0 z-50 flex items-center justify-between">
|
||
<div class="flex items-center gap-4"><button onclick="history.back()"
|
||
class="w-10 h-10 rounded-full border border-line flex items-center justify-center"><i
|
||
class="fa-solid fa-arrow-left text-sm"></i></button>
|
||
<h1 class="text-xl font-bold tracking-tight">Loyalty Setup</h1>
|
||
</div><button
|
||
class="bg-accent text-white text-xs font-bold uppercase tracking-widest px-5 py-2.5 rounded-full">Save</button>
|
||
</header>
|
||
<main id="main" class="mt-6 px-5 space-y-8">
|
||
<!-- 1. Card Appearance -->
|
||
<section id="card-appearance" class="space-y-4">
|
||
<h3
|
||
class="text-[10px] font-bold uppercase tracking-widest text-muted ml-1">
|
||
Card Appearance</h3>
|
||
<div id="i3isp"
|
||
class="bg-white p-6 rounded-jumbo border border-line shadow-sm space-y-8 h-[620px]">
|
||
<!-- Back of Card -->
|
||
<div id="back-image-section"></div><!-- Divider -->
|
||
<!-- Front Background — Toggle -->
|
||
<div id="front-background-section"
|
||
class="relative left-0 top-[-37px]">
|
||
<div class="flex items-center justify-between mb-5"><label
|
||
class="block text-xs font-bold">Background</label>
|
||
<!-- Toggle Switch -->
|
||
<div class="flex items-center gap-2 bg-stone rounded-full p-1">
|
||
<button id="btn-color" onclick="setFrontMode('color')"
|
||
class="px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest transition-all bg-ink text-white shadow">Color</button><button
|
||
id="btn-image" onclick="setFrontMode('image')"
|
||
class="px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest transition-all text-muted">Image</button>
|
||
</div>
|
||
</div><!-- Color Mode -->
|
||
<div id="front-color-mode">
|
||
<div class="flex items-center gap-3 flex-wrap"><button
|
||
onclick="selectColor(this, '#111b15')"
|
||
class="w-11 h-11 rounded-full bg-[#111b15] border-[3px] border-accent shadow-md ring-2 ring-accent/30"></button><button
|
||
onclick="selectColor(this, '#f4f6f4')"
|
||
class="w-11 h-11 rounded-full bg-[#f4f6f4] border-2 border-line"></button><button
|
||
onclick="selectColor(this, '#1e293b')"
|
||
class="w-11 h-11 rounded-full bg-[#1e293b] border-2 border-line"></button><button
|
||
onclick="selectColor(this, '#451a03')"
|
||
class="w-11 h-11 rounded-full bg-[#451a03] border-2 border-line"></button><button
|
||
onclick="selectColor(this, '#0f172a')"
|
||
class="w-11 h-11 rounded-full bg-[#0f172a] border-2 border-line"></button><button
|
||
onclick="selectColor(this, '#064e3b')"
|
||
class="w-11 h-11 rounded-full bg-[#064e3b] border-2 border-line"></button>
|
||
<!-- Custom --><label
|
||
class="w-11 h-11 rounded-full border-2 border-dashed border-line flex items-center justify-center text-muted cursor-pointer relative overflow-hidden"><i
|
||
class="fa-solid fa-plus text-xs"></i><input type="color"
|
||
onchange="handleCustomColor(this)"
|
||
class="absolute inset-0 opacity-0 cursor-pointer w-full h-full"></label>
|
||
</div>
|
||
<div id="selected-color-preview"
|
||
class="mt-4 flex items-center gap-3">
|
||
<div
|
||
class="w-6 h-6 rounded-full bg-[#111b15] border border-line">
|
||
</div><span class="text-xs text-muted font-medium">#111b15 —
|
||
Midnight Ink</span>
|
||
</div>
|
||
</div><!-- Image Mode (hidden by default) -->
|
||
<div id="front-image-mode" class="hidden">
|
||
<div
|
||
class="relative w-full aspect-video rounded-xl overflow-hidden border-2 border-dashed border-line group cursor-pointer">
|
||
<div
|
||
class="absolute inset-0 flex flex-col items-center justify-center text-muted gap-3">
|
||
<div
|
||
class="w-14 h-14 rounded-full bg-stone flex items-center justify-center">
|
||
<i class="fa-solid fa-image text-2xl"></i></div>
|
||
<p class="text-xs font-bold uppercase tracking-widest">Tap to
|
||
upload</p>
|
||
<p class="text-[10px]">JPG, PNG or WebP — max 5MB</p>
|
||
</div>
|
||
</div>
|
||
<p class="text-[10px] text-muted mt-2 uppercase tracking-widest">
|
||
Image will be used as the front card background</p>
|
||
</div>
|
||
</div><!-- Divider -->
|
||
<div id="idxtw" class="border-t border-line relative top-[-38px]">
|
||
</div><!-- Stamp Icon -->
|
||
<div id="stamp-section" class="relative top-[-44px]"><label
|
||
class="block text-xs font-bold mb-3">Stamp Icon / Image</label>
|
||
<div class="grid grid-cols-4 gap-3"><button
|
||
onclick="selectStamp(this)"
|
||
class="aspect-square bg-stone rounded-xl flex items-center justify-center text-2xl text-ink border-2 border-accent shadow-sm"><i
|
||
class="fa-solid fa-mustache"></i></button><button
|
||
onclick="selectStamp(this)"
|
||
class="aspect-square bg-stone rounded-xl flex items-center justify-center text-2xl text-muted border-2 border-transparent"><i
|
||
class="fa-solid fa-scissors"></i></button><button
|
||
onclick="selectStamp(this)"
|
||
class="aspect-square bg-stone rounded-xl flex items-center justify-center text-2xl text-muted border-2 border-transparent"><i
|
||
class="fa-solid fa-crown"></i></button><button
|
||
onclick="selectStamp(this)"
|
||
class="aspect-square bg-stone rounded-xl flex items-center justify-center text-2xl text-muted border-2 border-transparent"><i
|
||
class="fa-solid fa-star"></i></button><button
|
||
onclick="selectStamp(this)"
|
||
class="aspect-square bg-stone rounded-xl flex items-center justify-center text-2xl text-muted border-2 border-transparent"><i
|
||
class="fa-solid fa-fire"></i></button><button
|
||
onclick="selectStamp(this)"
|
||
class="aspect-square bg-stone rounded-xl flex items-center justify-center text-2xl text-muted border-2 border-transparent"><i
|
||
class="fa-solid fa-bolt"></i></button><button
|
||
onclick="selectStamp(this)"
|
||
class="aspect-square bg-stone rounded-xl flex items-center justify-center text-2xl text-muted border-2 border-transparent"><i
|
||
class="fa-solid fa-gem"></i></button><button
|
||
class="aspect-square border-2 border-dashed border-line rounded-xl flex flex-col items-center justify-center text-muted gap-1"><i
|
||
class="fa-solid fa-plus text-xs"></i><span
|
||
class="text-[9px] font-bold uppercase tracking-widest">Upload</span></button>
|
||
</div>
|
||
</div><!-- Divider -->
|
||
<div class="border-t border-line relative top-[-50px]" id="i0k6o">
|
||
</div><!-- Terms Line -->
|
||
<div id="terms-section" class="relative top-[-57px]"><label
|
||
class="block text-xs font-bold mb-1">Terms Line <span
|
||
class="text-muted font-normal">(shown below card)</span></label>
|
||
<p class="text-[10px] text-muted uppercase tracking-widest mb-3">Max
|
||
60 characters</p>
|
||
<div class="relative"><input id="terms-input" type="text"
|
||
maxlength="60"
|
||
value="Valid for 12 months. One reward per visit."
|
||
oninput="updateCharCount(this)"
|
||
placeholder="e.g. Valid for 12 months. One reward per visit."
|
||
class="w-full bg-stone border border-line rounded-xl px-4 py-4 text-sm font-medium outline-none focus:border-accent transition-colors pr-16"><span
|
||
id="char-count"
|
||
class="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-bold text-muted">42/60</span>
|
||
</div><!-- Preview -->
|
||
</div>
|
||
</div>
|
||
</section><!-- 2. Reward Rules -->
|
||
<section id="reward-rules" class="space-y-4">
|
||
<h3
|
||
class="text-[10px] font-bold uppercase tracking-widest text-muted ml-1">
|
||
Reward Rules</h3>
|
||
<div
|
||
class="bg-white p-6 rounded-jumbo border border-line shadow-sm space-y-8">
|
||
<!-- Stamps Required -->
|
||
<div id="stamps-required"><label
|
||
class="block text-xs font-bold mb-4">Stamps required for
|
||
reward</label>
|
||
<div class="flex items-center gap-6"><button
|
||
onclick="adjustStamps(-1)"
|
||
class="w-12 h-12 rounded-full border-2 border-line flex items-center justify-center text-lg font-bold hover:border-accent hover:text-accent transition-all">−</button>
|
||
<div class="text-center"><span id="stamp-count"
|
||
class="text-4xl font-black">10</span>
|
||
<p
|
||
class="text-[10px] text-muted uppercase tracking-widest mt-1">
|
||
stamps</p>
|
||
</div><button onclick="adjustStamps(1)"
|
||
class="w-12 h-12 rounded-full border-2 border-line flex items-center justify-center text-lg font-bold hover:border-accent hover:text-accent transition-all">+</button>
|
||
</div><!-- Visual dots preview -->
|
||
<div id="stamp-dots" class="flex flex-wrap gap-2 mt-5">
|
||
</div>
|
||
<p class="text-[10px] text-muted mt-3 uppercase tracking-widest">
|
||
Recommended: 8–10 stamps</p>
|
||
</div><!-- Divider -->
|
||
<div class="border-t border-line"></div><!-- Reward Type -->
|
||
<div id="reward-type-section"><label
|
||
class="block text-xs font-bold mb-4">Reward Type</label>
|
||
<div class="grid grid-cols-3 gap-2 mb-5"><button
|
||
onclick="selectReward(this, 'percent')"
|
||
class="py-3.5 border-2 border-accent bg-accent/5 rounded-xl text-[10px] font-bold uppercase tracking-widest text-ink"><i
|
||
class="fa-solid fa-percent block text-base mb-1"></i>Percentage
|
||
</button><button onclick="selectReward(this, 'fixed')"
|
||
class="py-3.5 border border-line bg-white rounded-xl text-[10px] font-bold uppercase tracking-widest text-muted"><i
|
||
class="fa-solid fa-sterling-sign block text-base mb-1"></i>Fixed
|
||
Off
|
||
</button><button onclick="selectReward(this, 'free')"
|
||
class="py-3.5 border border-line bg-white rounded-xl text-[10px] font-bold uppercase tracking-widest text-muted"><i
|
||
class="fa-solid fa-gift block text-base mb-1"></i>Free
|
||
</button></div><!-- Value input (hidden for free) -->
|
||
<div id="reward-value-wrap">
|
||
<div class="relative mb-4"><span id="reward-symbol"
|
||
class="absolute left-4 top-1/2 -translate-y-1/2 font-black text-ink">%</span><input
|
||
id="reward-value" type="number" value="50"
|
||
class="w-full bg-stone border border-line rounded-xl pl-10 pr-4 py-4 font-bold text-lg outline-none focus:border-accent transition-colors">
|
||
</div>
|
||
</div><!-- Reward label -->
|
||
<div><label
|
||
class="block text-[10px] font-bold uppercase tracking-widest text-muted mb-2 ml-1">Reward
|
||
Display Name</label><input id="reward-name" type="text"
|
||
value="50% Off Any Service" placeholder="e.g. Next Haircut Free"
|
||
class="w-full bg-stone border border-line rounded-xl px-4 py-4 text-sm font-medium outline-none focus:border-accent transition-colors">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section><!-- Danger Zone -->
|
||
<section id="danger-zone" class="pb-2"><button
|
||
class="w-full py-4 border-2 border-dashed border-red-200 rounded-xl text-[10px] font-bold uppercase tracking-widest text-red-400 hover:border-red-500 hover:text-red-500 transition-all flex items-center justify-center gap-2"><i
|
||
class="fa-solid fa-ban"></i> Disable Loyalty Program
|
||
</button></section>
|
||
</main><!-- Bottom Nav -->
|
||
<nav id="bottom-nav"
|
||
class="fixed bottom-0 left-0 right-0 z-50 bg-[#0c0c0c] text-gray-400 py-4 px-10 border-t border-white/5 flex items-center justify-between">
|
||
<a href="#"
|
||
class="flex flex-col items-center gap-1 hover:text-white transition-colors"><i
|
||
class="fa-solid fa-calendar-days text-xl"></i><span
|
||
class="text-[10px] font-medium">Calendar</span></a><a href="#"
|
||
class="flex flex-col items-center gap-1 hover:text-white transition-colors"><i
|
||
class="fa-solid fa-scissors text-xl"></i><span
|
||
class="text-[10px] font-medium">Current</span></a><a href="#"
|
||
class="flex flex-col items-center gap-1 hover:text-white transition-colors"><i
|
||
class="fa-solid fa-users text-xl"></i><span
|
||
class="text-[10px] font-medium">Clients</span></a><a href="#"
|
||
class="flex flex-col items-center gap-1 text-white"><i
|
||
class="fa-solid fa-gear text-xl"></i><span
|
||
class="text-[10px] font-medium">Settings</span></a></nav>
|
||
<script>
|
||
// ── Front background toggle ──────────────────────────────────────
|
||
function setFrontMode(mode)
|
||
{
|
||
const btnColor = document.getElementById('btn-color');
|
||
const btnImage = document.getElementById('btn-image');
|
||
const colorMode = document.getElementById('front-color-mode');
|
||
const imageMode = document.getElementById('front-image-mode');
|
||
|
||
if (mode === 'color')
|
||
{
|
||
btnColor.classList.add('bg-ink', 'text-white', 'shadow');
|
||
btnColor.classList.remove('text-muted');
|
||
btnImage.classList.remove('bg-ink', 'text-white', 'shadow');
|
||
btnImage.classList.add('text-muted');
|
||
colorMode.classList.remove('hidden');
|
||
imageMode.classList.add('hidden');
|
||
}
|
||
else
|
||
{
|
||
btnImage.classList.add('bg-ink', 'text-white', 'shadow');
|
||
btnImage.classList.remove('text-muted');
|
||
btnColor.classList.remove('bg-ink', 'text-white', 'shadow');
|
||
btnColor.classList.add('text-muted');
|
||
imageMode.classList.remove('hidden');
|
||
colorMode.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// ── Color selection ──────────────────────────────────────────────
|
||
const colorNames = {
|
||
'#111b15': 'Midnight Ink',
|
||
'#f4f6f4': 'Cloud Stone',
|
||
'#1e293b': 'Slate Deep',
|
||
'#451a03': 'Mahogany',
|
||
'#0f172a': 'Void Blue',
|
||
'#064e3b': 'Forest Dark',
|
||
};
|
||
|
||
function selectColor(btn, hex)
|
||
{
|
||
document.querySelectorAll('#front-color-mode button[onclick]').forEach(
|
||
b =>
|
||
{
|
||
b.classList.remove('border-accent', 'ring-2', 'ring-accent/30',
|
||
'shadow-md');
|
||
b.classList.add('border-line');
|
||
b.style.borderWidth = '2px';
|
||
});
|
||
btn.classList.add('border-accent', 'ring-2', 'ring-accent/30',
|
||
'shadow-md');
|
||
btn.classList.remove('border-line');
|
||
btn.style.borderWidth = '3px';
|
||
|
||
const preview = document.getElementById('selected-color-preview');
|
||
const name = colorNames[hex] || 'Custom';
|
||
preview.querySelector('div').style.backgroundColor = hex;
|
||
preview.querySelector('span').textContent = `${hex} — ${name}`;
|
||
}
|
||
|
||
function handleCustomColor(input)
|
||
{
|
||
colorNames[input.value] = 'Custom';
|
||
}
|
||
|
||
// ── Stamp icon selection ─────────────────────────────────────────
|
||
function selectStamp(btn)
|
||
{
|
||
document.querySelectorAll('#stamp-section button[onclick]').forEach(b =>
|
||
{
|
||
b.classList.remove('border-accent', 'text-ink');
|
||
b.classList.add('border-transparent', 'text-muted');
|
||
});
|
||
btn.classList.add('border-accent', 'text-ink');
|
||
btn.classList.remove('border-transparent', 'text-muted');
|
||
}
|
||
|
||
// ── Stamps counter & dots ────────────────────────────────────────
|
||
let stampCount = 10;
|
||
|
||
function adjustStamps(delta)
|
||
{
|
||
stampCount = Math.min(20, Math.max(3, stampCount + delta));
|
||
document.getElementById('stamp-count').textContent = stampCount;
|
||
renderDots();
|
||
}
|
||
|
||
function renderDots()
|
||
{
|
||
const container = document.getElementById('stamp-dots');
|
||
container.innerHTML = '';
|
||
for (let i = 0; i < stampCount; i++)
|
||
{
|
||
const dot = document.createElement('div');
|
||
dot.className = 'w-6 h-6 rounded-full border-2 border-line bg-stone';
|
||
container.appendChild(dot);
|
||
}
|
||
}
|
||
|
||
renderDots();
|
||
|
||
// ── Reward type ──────────────────────────────────────────────────
|
||
function selectReward(btn, type)
|
||
{
|
||
document.querySelectorAll('#reward-type-section .grid button').forEach(
|
||
b =>
|
||
{
|
||
b.classList.remove('border-accent', 'bg-accent/5', 'text-ink');
|
||
b.classList.add('border-line', 'text-muted');
|
||
});
|
||
btn.classList.add('border-accent', 'bg-accent/5', 'text-ink');
|
||
btn.classList.remove('border-line', 'text-muted');
|
||
|
||
const valueWrap = document.getElementById('reward-value-wrap');
|
||
const symbol = document.getElementById('reward-symbol');
|
||
const rewardName = document.getElementById('reward-name');
|
||
|
||
if (type === 'free')
|
||
{
|
||
valueWrap.classList.add('hidden');
|
||
rewardName.value = 'Next Service Free';
|
||
}
|
||
else if (type === 'percent')
|
||
{
|
||
valueWrap.classList.remove('hidden');
|
||
symbol.textContent = '%';
|
||
rewardName.value = '50% Off Any Service';
|
||
}
|
||
else
|
||
{
|
||
valueWrap.classList.remove('hidden');
|
||
symbol.textContent = '£';
|
||
rewardName.value = '£5 Off Next Visit';
|
||
}
|
||
}
|
||
|
||
// ── Terms character counter ──────────────────────────────────────
|
||
function updateCharCount(input)
|
||
{
|
||
const len = input.value.length;
|
||
const counter = document.getElementById('char-count');
|
||
counter.textContent = `${len}/60`;
|
||
counter.classList.toggle('text-red-500', len >= 55);
|
||
counter.classList.toggle('text-muted', len < 55);
|
||
document.getElementById('terms-preview').textContent = input.value ||
|
||
'—';
|
||
}
|
||
|
||
// Initialise char count
|
||
updateCharCount(document.getElementById('terms-input'));
|
||
</script>
|
||
</body>
|
||
|
||
</html> |