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

+104
View File
@@ -0,0 +1,104 @@
"use client";
import { Scissors } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils/cn";
export interface LoyaltyCardProps {
businessName: string;
memberNumber: string;
stampsRequired: number;
stampsEarned: number;
rewardLabel: string;
termsLine?: string;
}
/**
* Ports the 3D flip loyalty card from templates/11 — the best-executed
* visual in the whole template set (see docs/implementation-plan.md §2) —
* but fixes the gap flagged in §8: the original had no actual flip
* trigger wired up. Here the card is a real <button> with
* `aria-pressed`, and the flip transition is skipped for users who
* prefer reduced motion.
*/
export function LoyaltyCard({
businessName,
memberNumber,
stampsRequired,
stampsEarned,
rewardLabel,
termsLine,
}: LoyaltyCardProps) {
const [flipped, setFlipped] = useState(false);
return (
<button
type="button"
onClick={() => setFlipped((v) => !v)}
aria-pressed={flipped}
aria-label={flipped ? "Show stamp side" : "Show card back"}
className="w-full [perspective:1000px]"
style={{ aspectRatio: "1.65 / 1" }}
>
<div
className={cn(
"relative h-full w-full rounded-2xl shadow-[0_30px_60px_rgba(0,0,0,0.45)] transition-transform duration-700 [transform-style:preserve-3d] motion-reduce:transition-none",
"ring-[3px] ring-accent-loyalty",
)}
style={{ transform: flipped ? "rotateY(180deg)" : "rotateY(0deg)" }}
>
{/* Front — stamps */}
<div className="absolute inset-0 flex flex-col justify-between overflow-hidden rounded-2xl bg-[#141414] p-4 [backface-visibility:hidden]">
<div className="flex items-center justify-between">
<h2 className="text-lg font-black italic tracking-tight text-white">Loyalty Card</h2>
<span className="rounded border border-accent-loyalty/30 px-2 py-0.5 text-[8px] font-bold uppercase tracking-widest text-accent-loyalty">
Member #{memberNumber}
</span>
</div>
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: stampsRequired }, (_, i) => {
const earned = i < stampsEarned;
const isRewardSlot = i === stampsRequired - 1;
return (
<div
key={i}
className={cn(
"flex aspect-square items-center justify-center rounded-full",
isRewardSlot
? "scale-[1.08] bg-accent-loyalty shadow-lg"
: earned
? "bg-white shadow-inner"
: "border-2 border-dashed border-white/20 bg-[#2a2a2a]",
)}
>
{isRewardSlot ? (
<span className="text-center text-[7px] font-black leading-tight text-ink">
{rewardLabel}
</span>
) : earned ? (
<Scissors className="h-3.5 w-3.5 text-ink" aria-hidden="true" />
) : null}
</div>
);
})}
</div>
{termsLine && (
<p className="text-center text-[7px] font-bold uppercase tracking-widest text-white/40">
{termsLine}
</p>
)}
</div>
{/* Back — brand */}
<div className="absolute inset-0 flex items-center justify-center rounded-2xl bg-ink [backface-visibility:hidden] [transform:rotateY(180deg)]">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-3xl text-ink">
<Scissors className="h-7 w-7" aria-hidden="true" />
</div>
<h3 className="text-2xl font-black uppercase tracking-tighter text-white">{businessName}</h3>
</div>
</div>
</div>
</button>
);
}