You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
195 lines
6.6 KiB
195 lines
6.6 KiB
import Link from "next/link";
|
|
import { useEffect, useRef } from "react";
|
|
import { IoInformation } from "react-icons/io5";
|
|
import {
|
|
type QuestionCardIcon,
|
|
type QuestionListItem,
|
|
resolveSectionIcon,
|
|
} from "@/lib/schema-adapter";
|
|
import { localizePath } from "@/translations/config";
|
|
import { useI18n } from "@/translations/provider";
|
|
import { UiIcon, type UiIconName } from "./ui-icon";
|
|
|
|
type QuestionCardProps = {
|
|
item: QuestionListItem;
|
|
progress?: number | null;
|
|
onInfoClick?: (item: QuestionListItem) => void;
|
|
onNearViewport?: (item: QuestionListItem) => void;
|
|
onPrefetch?: (item: QuestionListItem) => void;
|
|
onSelect?: (item: QuestionListItem) => void;
|
|
};
|
|
|
|
const RADIUS = 8;
|
|
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
|
|
|
// Inline equivalents of the per-card SVGs previously loaded from `public/`:
|
|
// no network request, present in the first rendered markup.
|
|
const iconNameMap: Record<QuestionCardIcon, UiIconName> = {
|
|
profile: "person",
|
|
contact: "contact",
|
|
health: "health",
|
|
education: "education",
|
|
family: "family",
|
|
marriage: "marriage",
|
|
lifestyle: "lifestyle",
|
|
criteria: "criteria",
|
|
verification: "verification",
|
|
personality: "personality",
|
|
glasser: "glasser",
|
|
details: "details",
|
|
checklist: "checklist",
|
|
family_marital: "marriage",
|
|
};
|
|
|
|
export function QuestionCard({
|
|
item,
|
|
progress = item.progress,
|
|
onInfoClick,
|
|
onNearViewport,
|
|
onPrefetch,
|
|
onSelect,
|
|
}: QuestionCardProps) {
|
|
const { dictionary: t, locale } = useI18n();
|
|
const hasProgress = typeof progress === "number" && Number.isFinite(progress);
|
|
const normalizedProgress = hasProgress
|
|
? Math.max(0, Math.min(Math.round(progress), 100))
|
|
: 0;
|
|
const dashOffset = CIRCUMFERENCE - (normalizedProgress / 100) * CIRCUMFERENCE;
|
|
const resolvedIconKey = item.icon || resolveSectionIcon(item.slug);
|
|
const iconName = iconNameMap[resolvedIconKey] ?? "details";
|
|
const cardRef = useRef<HTMLElement>(null);
|
|
|
|
useEffect(() => {
|
|
const node = cardRef.current;
|
|
if (
|
|
!node ||
|
|
!onNearViewport ||
|
|
typeof IntersectionObserver === "undefined"
|
|
) {
|
|
return;
|
|
}
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting) {
|
|
onNearViewport(item);
|
|
observer.disconnect();
|
|
}
|
|
},
|
|
{ rootMargin: "160px 0px" },
|
|
);
|
|
observer.observe(node);
|
|
return () => observer.disconnect();
|
|
}, [item, onNearViewport]);
|
|
|
|
return (
|
|
<Link
|
|
href={localizePath(`/questions-list/${item.slug}`, locale)}
|
|
prefetch={true}
|
|
aria-label={t["Open {title}"].replace("{title}", item.title)}
|
|
className="block rounded-[20px] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#F26C85]"
|
|
onClick={(e) => {
|
|
if (onSelect && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.button === 0) {
|
|
e.preventDefault();
|
|
onSelect(item);
|
|
}
|
|
}}
|
|
onFocus={() => onPrefetch?.(item)}
|
|
onPointerEnter={() => onPrefetch?.(item)}
|
|
onPointerDown={() => onPrefetch?.(item)}
|
|
>
|
|
<article
|
|
ref={cardRef}
|
|
id={item.slug}
|
|
data-question-slug={item.slug}
|
|
className="rounded-[20px] border border-white/80 bg-white px-3 py-3 shadow-[0_12px_28px_rgba(15,23,42,0.05)] transition-transform duration-200 hover:-translate-y-0.5"
|
|
>
|
|
<div className="flex items-start gap-2.5">
|
|
<div className="relative flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-[12px] bg-linear-to-br from-[#E03950]/15 to-[#FE6F82]/15 text-white">
|
|
<UiIcon
|
|
name={iconName}
|
|
aria-hidden="true"
|
|
className="size-[22px]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<h2 className="group-14 leading-tight font-bold text-[#1B1B1B] line-clamp-2">
|
|
{item.title}
|
|
</h2>
|
|
<p className="mt-1.5 group-10 leading-none font-medium text-[#7A7A7A]">
|
|
{t["Estimate time"]}: {item.estimate}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex min-h-[44px] shrink-0 flex-col items-end justify-between self-stretch">
|
|
{item.required ? (
|
|
<span className="flex items-center justify-center rounded-full bg-[#F8D7DA] dark:bg-semantic-danger-bg px-3 py-1 text-center text-[10px] font-bold text-[#D9383A] dark:text-semantic-danger-text whitespace-nowrap">
|
|
{t["Required"]}
|
|
</span>
|
|
) : item.showInfoBadge ? (
|
|
<button
|
|
type="button"
|
|
aria-label={`${item.title} details`}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onInfoClick?.(item);
|
|
}}
|
|
className="flex h-[17px] w-[17px] cursor-pointer items-center justify-center rounded-[6px] bg-[#747474] text-white border-0 p-0 hover:bg-[#606060] transition-colors"
|
|
>
|
|
<IoInformation aria-hidden="true" className="text-[9px]" />
|
|
</button>
|
|
) : (
|
|
<span className="h-[17px] w-[17px]" aria-hidden="true" />
|
|
)}
|
|
|
|
<div className="mt-auto flex items-center gap-1">
|
|
{progress === 100 ? (
|
|
<UiIcon name="success" aria-hidden="true" className="size-3" />
|
|
) : (
|
|
<svg
|
|
aria-hidden="true"
|
|
className="-rotate-90"
|
|
viewBox="0 0 22 22"
|
|
width="18"
|
|
height="18"
|
|
>
|
|
<circle
|
|
cx="11"
|
|
cy="11"
|
|
r={RADIUS}
|
|
fill="none"
|
|
stroke="#E9E9E9"
|
|
strokeWidth="2.25"
|
|
/>
|
|
<circle
|
|
cx="11"
|
|
cy="11"
|
|
r={RADIUS}
|
|
fill="none"
|
|
stroke="#202020"
|
|
strokeWidth="2.25"
|
|
strokeLinecap="round"
|
|
strokeDasharray={CIRCUMFERENCE}
|
|
strokeDashoffset={dashOffset}
|
|
/>
|
|
</svg>
|
|
)}
|
|
<span className="group-10 leading-none font-semibold text-[#1F1F1F]">
|
|
{hasProgress ? `${normalizedProgress}%` : "..."}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{item.note ? (
|
|
<p className="mt-2 rounded-full bg-[#FFF1F4] px-3 py-1.5 text-center group-10 leading-none font-medium text-[#F35A74]">
|
|
{item.note}
|
|
</p>
|
|
) : null}
|
|
</article>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export default QuestionCard;
|