B2B-99: add package comparison modal (#27)

* B2B-99: add pacakge comparison modal

* B2B-99: add package comparison modal

---------

Co-authored-by: Helena <helena@Helenas-MacBook-Pro.local>
This commit is contained in:
Helena
2025-07-02 16:41:58 +03:00
committed by GitHub
parent 04e0bc8069
commit a7ca3945bf
16 changed files with 380 additions and 30 deletions

View File

@@ -0,0 +1,204 @@
import { JSX } from 'react';
import { QuestionMarkCircledIcon } from '@radix-ui/react-icons';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { Check, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PackageHeader } from '../../../../components/package-header';
import { InfoTooltip } from '../../../../components/ui/info-tooltip';
const dummyCards = [
{
titleKey: 'product:standard.label',
price: 40,
nrOfAnalyses: 4,
tagColor: 'bg-cyan',
},
{
titleKey: 'product:standardPlus.label',
price: 85,
nrOfAnalyses: 10,
tagColor: 'bg-warning',
},
{
titleKey: 'product:premium.label',
price: 140,
nrOfAnalyses: '12+',
tagColor: 'bg-purple',
},
];
const dummyRows = [
{
analysisNameKey: 'product:clinicalBloodDraw.label',
tooltipContentKey: 'product:clinicalBloodDraw.description',
includedInStandard: 1,
includedInStandardPlus: 1,
includedInPremium: 1,
},
{
analysisNameKey: 'product:crp.label',
tooltipContentKey: 'product:crp.description',
includedInStandard: 1,
includedInStandardPlus: 1,
includedInPremium: 1,
},
{
analysisNameKey: 'product:ferritin.label',
tooltipContentKey: 'product:ferritin.description',
includedInStandard: 0,
includedInStandardPlus: 1,
includedInPremium: 1,
},
{
analysisNameKey: 'product:vitaminD.label',
tooltipContentKey: 'product:vitaminD.description',
includedInStandard: 0,
includedInStandardPlus: 1,
includedInPremium: 1,
},
{
analysisNameKey: 'product:glucose.label',
tooltipContentKey: 'product:glucose.description',
includedInStandard: 1,
includedInStandardPlus: 1,
includedInPremium: 1,
},
{
analysisNameKey: 'product:alat.label',
tooltipContentKey: 'product:alat.description',
includedInStandard: 1,
includedInStandardPlus: 1,
includedInPremium: 1,
},
{
analysisNameKey: 'product:ast.label',
tooltipContentKey: 'product:ast.description',
includedInStandard: 1,
includedInStandardPlus: 1,
includedInPremium: 1,
},
];
const CheckWithBackground = () => {
return (
<div className="bg-primary w-min rounded-full p-1 text-white">
<Check className="size-3 stroke-2" />
</div>
);
};
const ComparePackagesModal = async ({
triggerElement,
}: {
triggerElement: JSX.Element;
}) => {
const { t, language } = await createI18nServerInstance();
return (
<Dialog>
<DialogTrigger asChild>{triggerElement}</DialogTrigger>
<DialogContent
className="min-h-screen max-w-fit min-w-screen"
customClose={
<div className="inline-flex place-items-center-safe gap-1 align-middle">
<p className="text-sm font-medium text-black">
{t('common:close')}
</p>
<X className="text-black" />
</div>
}
preventAutoFocus
>
<VisuallyHidden>
<DialogTitle>{t('common:comparePackages')}</DialogTitle>
</VisuallyHidden>
<div className="m-auto">
<div className="space-y-6 text-center">
<h3>{t('product:healthPackageComparison.label')}</h3>
<p className="text-muted-foreground mx-auto w-3/5 text-sm">
{t('product:healthPackageComparison.description')}
</p>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
{dummyCards.map(
({ titleKey, price, nrOfAnalyses, tagColor }) => (
<TableHead key={titleKey} className="py-2">
<PackageHeader
title={t(titleKey)}
tagColor={tagColor}
analysesNr={t('product:nrOfAnalyses', {
nr: nrOfAnalyses,
})}
language={language}
price={price}
/>
</TableHead>
),
)}
</TableRow>
</TableHeader>
<TableBody>
{dummyRows.map(
(
{
analysisNameKey,
tooltipContentKey,
includedInStandard,
includedInStandardPlus,
includedInPremium,
},
index,
) => (
<TableRow key={index}>
<TableCell className="py-6">
{t(analysisNameKey)}{' '}
<InfoTooltip
content={t(tooltipContentKey)}
icon={<QuestionMarkCircledIcon />}
/>
</TableCell>
<TableCell align="center" className="py-6">
{!!includedInStandard && <CheckWithBackground />}
</TableCell>
<TableCell align="center" className="py-6">
{!!includedInStandardPlus && <CheckWithBackground />}
</TableCell>
<TableCell align="center" className="py-6">
{!!includedInPremium && <CheckWithBackground />}
</TableCell>
</TableRow>
),
)}
</TableBody>
</Table>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default withI18n(ComparePackagesModal);

View File

@@ -20,6 +20,7 @@ import { MedReportLogo } from '../../components/med-report-logo';
import { PackageHeader } from '../../components/package-header';
import { ButtonTooltip } from '../../components/ui/button-tooltip';
import pathsConfig from '../../config/paths.config';
import ComparePackagesModal from '../home/(user)/_components/compare-packages-modal';
export const generateMetadata = async () => {
const { t } = await createI18nServerInstance();
@@ -63,10 +64,14 @@ async function SelectPackagePage() {
<MedReportLogo />
<div className="space-y-3 text-center">
<h3>{t('marketing:selectPackage')}</h3>
<ComparePackagesModal
triggerElement={
<Button variant="secondary" className="gap-2">
{t('marketing:comparePackages')}
<Scale className="size-4 stroke-[1.5px]" />
</Button>
}
/>
</div>
<div className="grid grid-cols-3 gap-6">
{dummyCards.map(

View File

@@ -1,6 +1,6 @@
import { formatCurrency } from "@kit/shared/utils";
import { Badge } from "@kit/ui/badge";
import { cn } from "@kit/ui/utils";
import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge';
import { cn } from '@kit/ui/utils';
export const PackageHeader = ({
title,
@@ -18,7 +18,6 @@ export const PackageHeader = ({
return (
<div className="space-y-1 text-center">
<p className="font-medium">{title}</p>
<Badge className={cn('text-xs', tagColor)}>{analysesNr}</Badge>
<h2>
{formatCurrency({
currencyCode: 'eur',
@@ -26,6 +25,7 @@ export const PackageHeader = ({
value: price,
})}
</h2>
<Badge className={cn('text-xs', tagColor)}>{analysesNr}</Badge>
</div>
);
};

View File

@@ -1,13 +1,27 @@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kit/ui/tooltip";
import { Info } from "lucide-react";
import { JSX } from 'react';
export function InfoTooltip({ content }: { content?: string }) {
import { Info } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
export function InfoTooltip({
content,
icon,
}: {
content?: string;
icon?: JSX.Element;
}) {
if (!content) return null;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="size-4 cursor-pointer" />
{icon || <Info className="size-4 cursor-pointer" />}
</TooltipTrigger>
<TooltipContent>{content}</TooltipContent>
</Tooltip>

View File

@@ -56,6 +56,7 @@
"@marsidev/react-turnstile": "^1.1.0",
"@nosecone/next": "1.0.0-beta.7",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-visually-hidden": "^1.2.3",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "2.49.4",
"@tanstack/react-query": "5.76.1",

View File

@@ -75,7 +75,7 @@ function PageWithHeader(props: PageProps) {
const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props);
return (
<div className={cn('flex h-screen flex-1 flex-col z-1000', props.className)}>
<div className={cn('flex h-screen flex-1 flex-col z-900', props.className)}>
<div
className={
props.contentContainerClassName ?? 'flex flex-1 flex-col space-y-4'

View File

@@ -7,12 +7,12 @@ import type { VariantProps } from 'class-variance-authority';
import { cn } from '../lib/utils';
const buttonVariants = cva(
'focus-visible:ring-ring gap-1 inline-flex items-center justify-center rounded-md text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
'inline-flex items-center justify-center gap-1 rounded-md text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground font-medium hover:bg-primary/90 shadow-xs',
'bg-primary text-primary-foreground hover:bg-primary/90 font-medium shadow-xs',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-xs',
outline:
@@ -23,7 +23,7 @@ const buttonVariants = cva(
link: 'decoration-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 mt-0 py-2 px-8',
default: 'mt-0 h-10 px-8 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',

View File

@@ -20,7 +20,7 @@ const DialogOverlay: React.FC<
> = ({ className, ...props }) => (
<DialogPrimitive.Overlay
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/30',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-1000 bg-black/30',
className,
)}
{...props}
@@ -29,25 +29,39 @@ const DialogOverlay: React.FC<
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent: React.FC<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
> = ({ className, children, ...props }) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
customClose?: React.JSX.Element;
preventAutoFocus?: boolean;
}
> = ({ className, children, customClose, preventAutoFocus, ...props }) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-1000 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
className,
)}
onOpenAutoFocus={
preventAutoFocus ? (e) => e.preventDefault() : props.onOpenAutoFocus
}
onCloseAutoFocus={
preventAutoFocus ? (e) => e.preventDefault() : props.onOpenAutoFocus
}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs transition-opacity hover:opacity-70 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
{customClose || (
<>
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</>
)}
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({

View File

@@ -6,8 +6,18 @@ import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '../lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;

5
pnpm-lock.yaml generated
View File

@@ -83,6 +83,9 @@ importers:
'@radix-ui/react-icons':
specifier: ^1.3.2
version: 1.3.2(react@19.1.0)
'@radix-ui/react-visually-hidden':
specifier: ^1.2.3
version: 1.2.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@supabase/ssr':
specifier: ^0.6.1
version: 0.6.1(@supabase/supabase-js@2.49.4)
@@ -13116,7 +13119,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 24.0.3
'@types/node': 22.15.32
merge-stream: 2.0.0
supports-color: 8.1.1

View File

@@ -17,6 +17,7 @@
"imageInputLabel": "Click here to upload an image",
"cancel": "Cancel",
"clear": "Clear",
"close": "Close",
"notFound": "Not Found",
"backToHomePage": "Back to Home Page",
"goBack": "Go Back",
@@ -59,6 +60,10 @@
"shoppingCart": "Shopping cart",
"search": "Search{{end}}",
"myActions": "My actions",
"healthPackageComparison": {
"label": "Health package comparison",
"description": "Alljärgnevalt on antud eelinfo (sugu, vanus ja kehamassiindeksi) põhjal tehtud personalne terviseauditi valik. Tabelis on võimalik soovitatud terviseuuringute paketile lisada üksikuid uuringuid juurde."
},
"routes": {
"home": "Home",
"overview": "Overview",

View File

@@ -11,5 +11,33 @@
"label": "Premium",
"description": "Sobib, kui soovid lisaks peamistele tervisenäitajatele ülevaadet, kas organismis on olulisemaid vitamiine ja mineraalaineid piisavalt."
},
"nrOfAnalyses": "{{nr}} analyses"
"nrOfAnalyses": "{{nr}} analyses",
"clinicalBloodDraw": {
"label": "Kliiniline vereanalüüs",
"description": "Pending"
},
"crp": {
"label": "C-reaktiivne valk (CRP)",
"description": "Pending"
},
"ferritin": {
"label": "Ferritiin",
"description": "Pending"
},
"vitaminD": {
"label": "D-vitamiin",
"description": "Pending"
},
"glucose": {
"label": "Glükoos",
"description": "Pending"
},
"alat": {
"label": "Alaniini aminotransferaas",
"description": "Pending"
},
"ast": {
"label": "Aspartaadi aminotransferaas",
"description": "Pending"
}
}

View File

@@ -17,6 +17,7 @@
"imageInputLabel": "Click here to upload an image",
"cancel": "Cancel",
"clear": "Clear",
"close": "Sulge",
"notFound": "Not Found",
"backToHomePage": "Back to Home Page",
"goBack": "Tagasi",
@@ -59,6 +60,10 @@
"shoppingCart": "Ostukorv",
"search": "Otsi{{end}}",
"myActions": "Minu toimingud",
"healthPackageComparison": {
"label": "Tervisepakketide võrdlus",
"description": "Alljärgnevalt on antud eelinfo (sugu, vanus ja kehamassiindeksi) põhjal tehtud personalne terviseauditi valik. Tabelis on võimalik soovitatud terviseuuringute paketile lisada üksikuid uuringuid juurde."
},
"routes": {
"home": "Home",
"overview": "Ülevaade",

View File

@@ -11,5 +11,33 @@
"label": "Premium",
"description": "Sobib, kui soovid lisaks peamistele tervisenäitajatele ülevaadet, kas organismis on olulisemaid vitamiine ja mineraalaineid piisavalt."
},
"nrOfAnalyses": "{{nr}} analüüsi"
"nrOfAnalyses": "{{nr}} analüüsi",
"clinicalBloodDraw": {
"label": "Kliiniline vereanalüüs",
"description": "Pending"
},
"crp": {
"label": "C-reaktiivne valk (CRP)",
"description": "Pending"
},
"ferritin": {
"label": "Ferritiin",
"description": "Pending"
},
"vitaminD": {
"label": "D-vitamiin",
"description": "Pending"
},
"glucose": {
"label": "Glükoos",
"description": "Pending"
},
"alat": {
"label": "Alaniini aminotransferaas",
"description": "Pending"
},
"ast": {
"label": "Aspartaadi aminotransferaas",
"description": "Pending"
}
}

View File

@@ -17,6 +17,7 @@
"imageInputLabel": "Click here to upload an image",
"cancel": "Cancel",
"clear": "Clear",
"close": "Close",
"notFound": "Not Found",
"backToHomePage": "Back to Home Page",
"goBack": "Go Back",
@@ -59,6 +60,10 @@
"shoppingCart": "Shopping cart",
"search": "Search{{end}}",
"myActions": "My actions",
"healthPackageComparison": {
"label": "Health package comparison",
"description": "Alljärgnevalt on antud eelinfo (sugu, vanus ja kehamassiindeksi) põhjal tehtud personalne terviseauditi valik. Tabelis on võimalik soovitatud terviseuuringute paketile lisada üksikuid uuringuid juurde."
},
"routes": {
"home": "Home",
"overview": "Overview",

View File

@@ -11,5 +11,33 @@
"label": "Premium",
"description": "Sobib, kui soovid lisaks peamistele tervisenäitajatele ülevaadet, kas organismis on olulisemaid vitamiine ja mineraalaineid piisavalt."
},
"nrOfAnalyses": "{{nr}} analyses"
"nrOfAnalyses": "{{nr}} analyses",
"clinicalBloodDraw": {
"label": "Kliiniline vereanalüüs",
"description": "Pending"
},
"crp": {
"label": "C-reaktiivne valk (CRP)",
"description": "Pending"
},
"ferritin": {
"label": "Ferritiin",
"description": "Pending"
},
"vitaminD": {
"label": "D-vitamiin",
"description": "Pending"
},
"glucose": {
"label": "Glükoos",
"description": "Pending"
},
"alat": {
"label": "Alaniini aminotransferaas",
"description": "Pending"
},
"ast": {
"label": "Aspartaadi aminotransferaas",
"description": "Pending"
}
}