feat(MED-121): use age+sex specific analysis package variants

This commit is contained in:
2025-08-25 11:50:03 +03:00
parent 195af1db3d
commit 38d73e27ad
8 changed files with 237 additions and 106 deletions

View File

@@ -22,8 +22,14 @@ import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { PackageHeader } from '@kit/shared/components/package-header'; import { PackageHeader } from '@kit/shared/components/package-header';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip'; import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { StoreProduct } from '@medusajs/types'; import { StoreProduct } from '@medusajs/types';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
import { withI18n } from '@/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export type AnalysisPackageElement = Pick<StoreProduct, 'title' | 'id' | 'description'> & {
isIncludedInStandard: boolean;
isIncludedInStandardPlus: boolean;
isIncludedInPremium: boolean;
};
const CheckWithBackground = () => { const CheckWithBackground = () => {
return ( return (
@@ -33,15 +39,15 @@ const CheckWithBackground = () => {
); );
}; };
const PackageTableHead = async ({ product, nrOfAnalyses }: { product: StoreProduct, nrOfAnalyses: number }) => { const PackageTableHead = async ({ product }: { product: AnalysisPackageWithVariant }) => {
const { t, language } = await createI18nServerInstance(); const { t, language } = await createI18nServerInstance();
const variant = product.variants?.[0];
const titleKey = product.title; const { title, price, nrOfAnalyses } = product;
const price = variant?.calculated_price?.calculated_amount ?? 0;
return ( return (
<TableHead className="py-2"> <TableHead className="py-2">
<PackageHeader <PackageHeader
title={t(titleKey)} title={t(title)}
tagColor='bg-cyan' tagColor='bg-cyan'
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })} analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
language={language} language={language}
@@ -56,24 +62,20 @@ const ComparePackagesModal = async ({
analysisPackageElements, analysisPackageElements,
triggerElement, triggerElement,
}: { }: {
analysisPackages: StoreProduct[]; analysisPackages: AnalysisPackageWithVariant[];
analysisPackageElements: StoreProduct[]; analysisPackageElements: AnalysisPackageElement[];
triggerElement: JSX.Element; triggerElement: JSX.Element;
}) => { }) => {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();
const standardPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'standard')!; const standardPackage = analysisPackages.find(({ isStandard }) => isStandard);
const standardPlusPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'standard-plus')!; const standardPlusPackage = analysisPackages.find(({ isStandardPlus }) => isStandardPlus);
const premiumPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'premium')!; const premiumPackage = analysisPackages.find(({ isPremium }) => isPremium);
if (!standardPackage || !standardPlusPackage || !premiumPackage) { if (!standardPackage || !standardPlusPackage || !premiumPackage) {
return null; return null;
} }
const standardPackageAnalyses = getAnalysisElementMedusaProductIds([standardPackage]);
const standardPlusPackageAnalyses = getAnalysisElementMedusaProductIds([standardPlusPackage]);
const premiumPackageAnalyses = getAnalysisElementMedusaProductIds([premiumPackage]);
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild>{triggerElement}</DialogTrigger> <DialogTrigger asChild>{triggerElement}</DialogTrigger>
@@ -103,9 +105,9 @@ const ComparePackagesModal = async ({
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead></TableHead>
<PackageTableHead product={standardPackage} nrOfAnalyses={standardPackageAnalyses.length} /> <PackageTableHead product={standardPackage} />
<PackageTableHead product={standardPlusPackage} nrOfAnalyses={standardPlusPackageAnalyses.length} /> <PackageTableHead product={standardPlusPackage} />
<PackageTableHead product={premiumPackage} nrOfAnalyses={premiumPackageAnalyses.length} /> <PackageTableHead product={premiumPackage} />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -115,29 +117,29 @@ const ComparePackagesModal = async ({
title, title,
id, id,
description, description,
isIncludedInStandard,
isIncludedInStandardPlus,
isIncludedInPremium,
}, },
index,
) => { ) => {
if (!title) { if (!title) {
return null; return null;
} }
const includedInStandard = standardPackageAnalyses.includes(id);
const includedInStandardPlus = standardPlusPackageAnalyses.includes(id);
const includedInPremium = premiumPackageAnalyses.includes(id);
return ( return (
<TableRow key={index}> <TableRow key={id}>
<TableCell className="py-6"> <TableCell className="py-6">
{title}{' '} {title}{' '}
{description && (<InfoTooltip content={description} icon={<QuestionMarkCircledIcon />} />)} {description && (<InfoTooltip content={description} icon={<QuestionMarkCircledIcon />} />)}
</TableCell> </TableCell>
<TableCell align="center" className="py-6"> <TableCell align="center" className="py-6">
{includedInStandard && <CheckWithBackground />} {isIncludedInStandard && <CheckWithBackground />}
</TableCell> </TableCell>
<TableCell align="center" className="py-6"> <TableCell align="center" className="py-6">
{(includedInStandard || includedInStandardPlus) && <CheckWithBackground />} {(isIncludedInStandard || isIncludedInStandardPlus) && <CheckWithBackground />}
</TableCell> </TableCell>
<TableCell align="center" className="py-6"> <TableCell align="center" className="py-6">
{(includedInStandard || includedInStandardPlus || includedInPremium) && <CheckWithBackground />} {(isIncludedInStandard || isIncludedInStandardPlus || isIncludedInPremium) && <CheckWithBackground />}
</TableCell> </TableCell>
</TableRow> </TableRow>
); );

View File

@@ -9,30 +9,39 @@ import {
CardFooter, CardFooter,
CardDescription, CardDescription,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { StoreProduct, StoreProductVariant } from '@medusajs/types'; import { StoreProduct } from '@medusajs/types';
import { useState } from 'react'; import { useState } from 'react';
import { handleAddToCart } from '~/lib/services/medusaCart.service'; import { handleAddToCart } from '~/lib/services/medusaCart.service';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip'; import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
export type OrderAnalysisCard = Pick<
StoreProduct, 'title' | 'description' | 'subtitle'
> & {
isAvailable: boolean;
variant: { id: string };
};
export default function OrderAnalysesCards({ export default function OrderAnalysesCards({
analyses, analyses,
countryCode, countryCode,
}: { }: {
analyses: StoreProduct[]; analyses: OrderAnalysisCard[];
countryCode: string; countryCode: string;
}) { }) {
const router = useRouter(); const router = useRouter();
const [isAddingToCart, setIsAddingToCart] = useState(false); const [isAddingToCart, setIsAddingToCart] = useState(false);
const handleSelect = async (selectedVariant: StoreProductVariant) => { const handleSelect = async (variantId: string) => {
if (!selectedVariant?.id || isAddingToCart) return null if (isAddingToCart) {
return null;
}
setIsAddingToCart(true); setIsAddingToCart(true);
try { try {
await handleAddToCart({ await handleAddToCart({
selectedVariant, selectedVariant: { id: variantId },
countryCode, countryCode,
}); });
setIsAddingToCart(false); setIsAddingToCart(false);
@@ -47,13 +56,11 @@ export default function OrderAnalysesCards({
<div className="grid grid-cols-3 gap-6 mt-4"> <div className="grid grid-cols-3 gap-6 mt-4">
{analyses.map(({ {analyses.map(({
title, title,
variants, variant,
description, description,
subtitle, subtitle,
status, isAvailable,
metadata,
}) => { }) => {
const isAvailable = status === 'published' && !!metadata?.analysisIdOriginal;
return ( return (
<Card <Card
key={title} key={title}
@@ -72,7 +79,7 @@ export default function OrderAnalysesCards({
size="icon" size="icon"
variant="outline" variant="outline"
className="px-2 text-black" className="px-2 text-black"
onClick={() => handleSelect(variants![0]!)} onClick={() => handleSelect(variant.id)}
> >
{isAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />} {isAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
</Button> </Button>

View File

@@ -3,6 +3,7 @@ import { cache } from 'react';
import { listProductTypes } from "@lib/data/products"; import { listProductTypes } from "@lib/data/products";
import { listRegions } from '@lib/data/regions'; import { listRegions } from '@lib/data/regions';
import { getProductCategories } from '@lib/data/categories'; import { getProductCategories } from '@lib/data/categories';
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
async function countryCodesLoader() { async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) => const countryCodes = await listRegions().then((regions) =>
@@ -34,7 +35,18 @@ async function analysesLoader() {
const category = productCategories.find(({ metadata }) => metadata?.page === 'order-analysis'); const category = productCategories.find(({ metadata }) => metadata?.page === 'order-analysis');
return { return {
analyses: category?.products ?? [], analyses: category?.products?.map<OrderAnalysisCard>(({ title, description, subtitle, variants, status, metadata }) => {
const variant = variants![0]!;
return {
title,
description,
subtitle,
variant: {
id: variant.id,
},
isAvailable: status === 'published' && !!metadata?.analysisIdOriginal,
};
}) ?? [],
countryCode, countryCode,
} }
} }

View File

@@ -1,9 +1,13 @@
import { cache } from 'react'; import { cache } from 'react';
import Isikukood, { Gender } from 'isikukood';
import { listProductTypes, listProducts } from "@lib/data/products"; import { listProductTypes, listProducts } from "@lib/data/products";
import { listRegions } from '@lib/data/regions'; import { listRegions } from '@lib/data/regions';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
import type { StoreProduct } from '@medusajs/types'; import type { StoreProduct } from '@medusajs/types';
import { loadCurrentUserAccount } from './load-user-account';
import { AnalysisPackageWithVariant } from '~/components/select-analysis-package';
import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
async function countryCodesLoader() { async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) => const countryCodes = await listRegions().then((regions) =>
@@ -19,36 +23,153 @@ async function productTypesLoader() {
} }
export const loadProductTypes = cache(productTypesLoader); export const loadProductTypes = cache(productTypesLoader);
async function analysisPackagesLoader() { function userSpecificVariantLoader({
const [countryCodes, productTypes] = await Promise.all([loadCountryCodes(), loadProductTypes()]); account,
const countryCode = countryCodes[0]!; }: {
account: AccountWithParams;
}) {
const { personal_code: personalCode } = account;
if (!personalCode) {
throw new Error('Personal code not found');
}
const parsed = new Isikukood(personalCode);
const ageRange = (() => {
const age = parsed.getAge();
if (age >= 18 && age <= 29) {
return '18-29';
}
if (age >= 30 && age <= 49) {
return '30-49';
}
if (age >= 50 && age <= 59) {
return '50-59';
}
if (age >= 60) {
return '60';
}
throw new Error('Age range not supported');
})();
const gender = parsed.getGender() === Gender.MALE ? 'M' : 'F';
let analysisPackages: StoreProduct[] = []; return ({
let analysisPackageElements: StoreProduct[] = []; product,
}: {
product: StoreProduct;
}) => {
const variants = product.variants;
if (!variants) {
return null;
}
const productType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages'); const variant = variants.find((v) => v.options?.every((o) => [ageRange, gender].includes(o.value)));
if (!productType) { if (!variant) {
return { analysisPackageElements, analysisPackages, countryCode }; return null;
}
return variant;
}
}
async function analysisPackageElementsLoader({
analysisPackagesWithVariant,
countryCode,
}: {
analysisPackagesWithVariant: AnalysisPackageWithVariant[];
countryCode: string;
}) {
const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds(analysisPackagesWithVariant);
if (analysisElementMedusaProductIds.length === 0) {
return [];
} }
const { response: { products } } = await listProducts({
countryCode,
queryParams: {
id: analysisElementMedusaProductIds,
limit: 100,
},
});
const standardPackage = analysisPackagesWithVariant.find(({ isStandard }) => isStandard);
const standardPlusPackage = analysisPackagesWithVariant.find(({ isStandardPlus }) => isStandardPlus);
const premiumPackage = analysisPackagesWithVariant.find(({ isPremium }) => isPremium);
if (!standardPackage || !standardPlusPackage || !premiumPackage) {
return [];
}
const standardPackageAnalyses = getAnalysisElementMedusaProductIds([standardPackage]);
const standardPlusPackageAnalyses = getAnalysisElementMedusaProductIds([standardPlusPackage]);
const premiumPackageAnalyses = getAnalysisElementMedusaProductIds([premiumPackage]);
return products.map(({ id, title, description }) => ({
id,
title,
description,
isIncludedInStandard: standardPackageAnalyses.includes(id),
isIncludedInStandardPlus: standardPlusPackageAnalyses.includes(id),
isIncludedInPremium: premiumPackageAnalyses.includes(id),
}));
}
async function analysisPackagesWithVariantLoader({
account,
countryCode,
}: {
account: AccountWithParams;
countryCode: string;
}) {
const productTypes = await loadProductTypes();
const productType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
if (!productType) {
return null;
}
const analysisPackagesResponse = await listProducts({ const analysisPackagesResponse = await listProducts({
countryCode, countryCode,
queryParams: { limit: 100, "type_id[0]": productType.id }, queryParams: { limit: 100, "type_id[0]": productType.id },
}); });
analysisPackages = analysisPackagesResponse.response.products;
const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds(analysisPackages); const getVariant = userSpecificVariantLoader({ account });
if (analysisElementMedusaProductIds.length > 0) { const analysisPackagesWithVariant = analysisPackagesResponse.response.products
const { response: { products } } = await listProducts({ .reduce((acc, product) => {
countryCode, const variant = getVariant({ product });
queryParams: { if (!variant) {
id: analysisElementMedusaProductIds, return acc;
limit: 100, }
}, return [
}); ...acc,
analysisPackageElements = products; {
variantId: variant.id,
nrOfAnalyses: getAnalysisElementMedusaProductIds([product]).length,
price: variant.calculated_price?.calculated_amount ?? 0,
title: product.title,
subtitle: product.subtitle,
description: product.description,
metadata: product.metadata,
isStandard: product.metadata?.analysisPackageTier === 'standard',
isStandardPlus: product.metadata?.analysisPackageTier === 'standard-plus',
isPremium: product.metadata?.analysisPackageTier === 'premium',
},
];
}, [] as AnalysisPackageWithVariant[]);
return analysisPackagesWithVariant;
}
async function analysisPackagesLoader() {
const account = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
} }
return { analysisPackageElements, analysisPackages, countryCode }; const countryCodes = await loadCountryCodes();
const countryCode = countryCodes[0]!;
const analysisPackagesWithVariant = await analysisPackagesWithVariantLoader({ account, countryCode });
if (!analysisPackagesWithVariant) {
return { analysisPackageElements: [], analysisPackages: [], countryCode };
}
const analysisPackageElements = await analysisPackageElementsLoader({ analysisPackagesWithVariant, countryCode });
return { analysisPackageElements, analysisPackages: analysisPackagesWithVariant, countryCode };
} }
export const loadAnalysisPackages = cache(analysisPackagesLoader); export const loadAnalysisPackages = cache(analysisPackagesLoader);

View File

@@ -31,7 +31,7 @@ export async function handleAddToCart({
selectedVariant, selectedVariant,
countryCode, countryCode,
}: { }: {
selectedVariant: StoreProductVariant selectedVariant: Pick<StoreProductVariant, 'id'>
countryCode: string countryCode: string
}) { }) {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();

View File

@@ -5,9 +5,10 @@ import { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { StoreProduct, StoreProductVariant } from '@medusajs/types'; import { StoreProduct } from '@medusajs/types';
import { Button } from '@medusajs/ui'; import { Button } from '@medusajs/ui';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { handleAddToCart } from '../../../../lib/services/medusaCart.service';
import { import {
Card, Card,
@@ -17,25 +18,24 @@ import {
CardHeader, CardHeader,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { handleAddToCart } from '../../../../lib/services/medusaCart.service';
import { getAnalysisElementMedusaProductIds } from '../../../../utils/medusa-product';
import { PackageHeader } from './package-header';
import { ButtonTooltip } from './ui/button-tooltip'; import { ButtonTooltip } from './ui/button-tooltip';
import { PackageHeader } from './package-header';
export interface IAnalysisPackage { export type AnalysisPackageWithVariant = Pick<StoreProduct, 'title' | 'description' | 'subtitle' | 'metadata'> & {
titleKey: string; variantId: string;
nrOfAnalyses: number;
price: number; price: number;
tagColor: string; isStandard: boolean;
descriptionKey: string; isStandardPlus: boolean;
} isPremium: boolean;
};
export default function SelectAnalysisPackage({ export default function SelectAnalysisPackage({
analysisPackage, analysisPackage,
countryCode, countryCode,
}: { }: {
analysisPackage: StoreProduct; analysisPackage: AnalysisPackageWithVariant;
countryCode: string; countryCode: string,
}) { }) {
const router = useRouter(); const router = useRouter();
const { const {
@@ -44,35 +44,21 @@ export default function SelectAnalysisPackage({
} = useTranslation(); } = useTranslation();
const [isAddingToCart, setIsAddingToCart] = useState(false); const [isAddingToCart, setIsAddingToCart] = useState(false);
const handleSelect = async (selectedVariant: StoreProductVariant) => {
if (!selectedVariant?.id) return null; const { nrOfAnalyses, variantId, title, subtitle = '', description = '', price } = analysisPackage;
const handleSelect = async () => {
setIsAddingToCart(true); setIsAddingToCart(true);
await handleAddToCart({ await handleAddToCart({
selectedVariant, selectedVariant: { id: variantId },
countryCode, countryCode,
}); });
setIsAddingToCart(false); setIsAddingToCart(false);
router.push('/home/cart'); router.push('/home/cart');
}; };
const titleKey = analysisPackage.title;
const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds([
analysisPackage,
]);
const nrOfAnalyses = analysisElementMedusaProductIds.length;
const description = analysisPackage.description ?? '';
const subtitle = analysisPackage.subtitle ?? '';
const variant = analysisPackage.variants?.[0];
if (!variant) {
return null;
}
const price = variant.calculated_price?.calculated_amount ?? 0;
return ( return (
<Card key={titleKey}> <Card key={title}>
<CardHeader className="relative"> <CardHeader className="relative">
{description && ( {description && (
<ButtonTooltip <ButtonTooltip
@@ -90,8 +76,8 @@ export default function SelectAnalysisPackage({
</CardHeader> </CardHeader>
<CardContent className="space-y-1 text-center"> <CardContent className="space-y-1 text-center">
<PackageHeader <PackageHeader
title={t(titleKey)} title={title}
tagColor="bg-cyan" tagColor='bg-cyan'
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })} analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
language={language} language={language}
price={price} price={price}
@@ -99,14 +85,8 @@ export default function SelectAnalysisPackage({
<CardDescription>{subtitle}</CardDescription> <CardDescription>{subtitle}</CardDescription>
</CardContent> </CardContent>
<CardFooter> <CardFooter>
<Button <Button className="w-full text-[10px] sm:text-sm" onClick={handleSelect} isLoading={isAddingToCart}>
className="w-full text-[10px] sm:text-sm" {!isAddingToCart && <Trans i18nKey='order-analysis-package:selectThisPackage' />}
onClick={() => handleSelect(variant)}
isLoading={isAddingToCart}
>
{!isAddingToCart && (
<Trans i18nKey="order-analysis-package:selectThisPackage" />
)}
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>

View File

@@ -1,14 +1,19 @@
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { StoreProduct } from '@medusajs/types';
import SelectAnalysisPackage from './select-analysis-package'; import SelectAnalysisPackage, { AnalysisPackageWithVariant } from './select-analysis-package';
export default function SelectAnalysisPackages({ analysisPackages, countryCode }: { analysisPackages: StoreProduct[], countryCode: string }) { export default function SelectAnalysisPackages({
analysisPackages,
countryCode,
}: {
analysisPackages: AnalysisPackageWithVariant[];
countryCode: string;
}) {
return ( return (
<div className="grid grid-cols-3 gap-6"> <div className="grid grid-cols-3 gap-6">
{analysisPackages.length > 0 ? analysisPackages.map( {analysisPackages.length > 0 ? analysisPackages.map(
(product) => ( (analysisPackage) => (
<SelectAnalysisPackage key={product.title} analysisPackage={product} countryCode={countryCode} /> <SelectAnalysisPackage key={analysisPackage.title} analysisPackage={analysisPackage} countryCode={countryCode} />
)) : ( )) : (
<h4> <h4>
<Trans i18nKey="order-analysis-package:noPackagesAvailable" /> <Trans i18nKey="order-analysis-package:noPackagesAvailable" />

View File

@@ -1,4 +1,8 @@
export const getAnalysisElementMedusaProductIds = (products: ({ metadata?: { analysisElementMedusaProductIds?: string } | null } | null)[]) => { export const getAnalysisElementMedusaProductIds = (products: ({
metadata?: {
analysisElementMedusaProductIds?: string;
} | null;
} | null)[]) => {
if (!products) { if (!products) {
return []; return [];
} }