Phase 2
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:
201 files changed
+31984
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user