This repository has been archived on 2026-08-11. You can view files and clone it, but cannot push or open issues or pull requests.
Gishen-B2B/src/components/members/member-id-card.tsx
kirukib 1f30f00da6 Finalize portal polish: profile, avatars, ID cards, and list UX.
Ship leftover Yimaru login/personas, sidebar sections, filter modal, chart/stat clip fixes, and matching feature docs so the polish work is fully committed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 21:57:07 +03:00

196 lines
7.1 KiB
TypeScript

"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { QRCodeSVG } from "qrcode.react";
import { Printer } from "lucide-react";
import { brand } from "@/data/brand";
import {
memberScanPayload,
resolveAvatarUrl,
resolveCardId,
} from "@/lib/avatar";
import type { Member, Organisation } from "@/types";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/shared/status-badge";
import { cn } from "@/lib/utils";
export type MemberIdCardProps = {
member: Member;
organisation: Pick<Organisation, "tradeName" | "legalName" | "joinCode">;
packageName?: string;
className?: string;
/** Hide the print toolbar (e.g. when embedding) */
hideActions?: boolean;
};
export function MemberIdCard({
member,
organisation,
packageName,
className,
hideActions = false,
}: MemberIdCardProps) {
const t = useTranslations("idCard");
const verificationId = member.verificationId ?? member.employeeId;
const cardId = resolveCardId({
cardId: member.cardId,
verificationId: member.verificationId,
employeeId: member.employeeId,
memberId: member.id,
});
const scanPayload = memberScanPayload(member.id, verificationId);
const photoUrl = resolveAvatarUrl(member.avatarUrl, member.id);
function handlePrint() {
window.print();
}
return (
<div className={cn("space-y-4", className)}>
{!hideActions ? (
<div className="flex flex-wrap items-center justify-between gap-3 print:hidden">
<div className="min-w-0">
<p className="text-sm font-medium">{t("title")}</p>
<p className="text-xs text-muted-foreground">{t("subtitle")}</p>
</div>
<Button
type="button"
size="sm"
className="gap-2"
onClick={handlePrint}
disabled={member.status === "offboarded"}
>
<Printer className="size-4" />
{t("print")}
</Button>
</div>
) : null}
{/* Screen preview + print target */}
<div className="flex justify-center print:block">
<div
id="member-id-card-print"
className={cn(
"member-id-card relative w-full max-w-[340px] overflow-hidden rounded-xl",
"border border-[#c5d97a] bg-white text-[#141414] shadow-sm",
"aspect-[85.6/54]",
"print:max-w-none print:rounded-none print:border print:shadow-none",
)}
>
{/* Accent stripe */}
<div
aria-hidden
className="absolute inset-x-0 top-0 h-1.5 bg-[#a8c73a]"
/>
<div
aria-hidden
className="pointer-events-none absolute -right-8 -top-10 size-28 rounded-full bg-[#a8c73a]/20"
/>
<div
aria-hidden
className="pointer-events-none absolute -bottom-10 -left-6 size-24 rounded-full bg-[#141414]/05"
/>
<div className="relative flex h-full flex-col p-3.5 sm:p-4">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Image
src={brand.logo}
alt={brand.shortName}
width={28}
height={28}
className="size-7 shrink-0 rounded-md bg-white object-contain ring-1 ring-[#e2e5d8]"
/>
<div className="min-w-0">
<p className="truncate font-display text-[11px] font-semibold leading-tight tracking-tight">
{brand.shortName}
</p>
<p className="truncate text-[9px] leading-tight text-[#5c5f56]">
{t("pharmacyId")}
</p>
</div>
</div>
<StatusBadge status={member.status} />
</div>
{/* Body */}
<div className="mt-2.5 flex min-h-0 flex-1 gap-3">
<div className="relative size-[72px] shrink-0 overflow-hidden rounded-lg bg-[#eef1e3] ring-1 ring-[#e2e5d8] sm:size-[80px]">
{/* External DiceBear portrait — not next/image remotePatterns */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photoUrl}
alt={member.name}
className="size-full object-cover"
/>
</div>
<div className="min-w-0 flex-1 space-y-1">
<p className="truncate font-display text-sm font-semibold leading-tight tracking-tight sm:text-base">
{member.name}
</p>
<p className="truncate text-[10px] text-[#5c5f56]">
{organisation.tradeName}
</p>
<dl className="mt-1.5 grid gap-0.5 text-[9px] leading-snug sm:text-[10px]">
<div className="flex gap-1.5">
<dt className="shrink-0 text-[#5c5f56]">{t("employeeId")}</dt>
<dd className="truncate font-mono font-medium">
{member.employeeId}
</dd>
</div>
<div className="flex gap-1.5">
<dt className="shrink-0 text-[#5c5f56]">{t("cardId")}</dt>
<dd className="truncate font-mono font-medium">{cardId}</dd>
</div>
{packageName ? (
<div className="flex gap-1.5">
<dt className="shrink-0 text-[#5c5f56]">{t("package")}</dt>
<dd className="truncate font-medium">{packageName}</dd>
</div>
) : null}
</dl>
</div>
<div className="flex shrink-0 flex-col items-center justify-center gap-0.5">
<div className="rounded-md bg-white p-0.5 ring-1 ring-[#e2e5d8]">
<QRCodeSVG
value={scanPayload}
size={64}
level="M"
bgColor="#ffffff"
fgColor="#141414"
marginSize={0}
/>
</div>
<span className="max-w-[68px] truncate text-center font-mono text-[7px] text-[#5c5f56]">
{verificationId}
</span>
</div>
</div>
{/* Footer */}
<div className="mt-2 flex items-end justify-between gap-2 border-t border-[#e2e5d8] pt-1.5">
<p className="text-[8px] leading-tight text-[#5c5f56] sm:text-[9px]">
{t("scanHint")}
{organisation.joinCode
? ` · ${t("orgCode")}: ${organisation.joinCode}`
: null}
</p>
<p className="shrink-0 font-mono text-[8px] text-[#5c5f56]">
{cardId}
</p>
</div>
</div>
</div>
</div>
<p className="text-center text-xs text-muted-foreground print:hidden">
{t("printHint")}
</p>
</div>
);
}