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.
This commit is contained in:
Stefan Lazic committed 2026-08-02 17:47:23 +01:00
1 parent 468d163299
commit 090646fdfd
201 files changed
+31984

No files matched your search

+418
View File
@@ -0,0 +1,418 @@
<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&amp;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>