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

View File

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

View File

@@ -3,6 +3,7 @@ import { cache } from 'react';
import { listProductTypes } from "@lib/data/products";
import { listRegions } from '@lib/data/regions';
import { getProductCategories } from '@lib/data/categories';
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) =>
@@ -34,7 +35,18 @@ async function analysesLoader() {
const category = productCategories.find(({ metadata }) => metadata?.page === 'order-analysis');
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,
}
}

View File

@@ -1,9 +1,13 @@
import { cache } from 'react';
import Isikukood, { Gender } from 'isikukood';
import { listProductTypes, listProducts } from "@lib/data/products";
import { listRegions } from '@lib/data/regions';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
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() {
const countryCodes = await listRegions().then((regions) =>
@@ -19,36 +23,153 @@ async function productTypesLoader() {
}
export const loadProductTypes = cache(productTypesLoader);
async function analysisPackagesLoader() {
const [countryCodes, productTypes] = await Promise.all([loadCountryCodes(), loadProductTypes()]);
const countryCode = countryCodes[0]!;
function userSpecificVariantLoader({
account,
}: {
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[] = [];
let analysisPackageElements: StoreProduct[] = [];
return ({
product,
}: {
product: StoreProduct;
}) => {
const variants = product.variants;
if (!variants) {
return null;
}
const productType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
if (!productType) {
return { analysisPackageElements, analysisPackages, countryCode };
const variant = variants.find((v) => v.options?.every((o) => [ageRange, gender].includes(o.value)));
if (!variant) {
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({
countryCode,
queryParams: { limit: 100, "type_id[0]": productType.id },
});
analysisPackages = analysisPackagesResponse.response.products;
const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds(analysisPackages);
if (analysisElementMedusaProductIds.length > 0) {
const { response: { products } } = await listProducts({
countryCode,
queryParams: {
id: analysisElementMedusaProductIds,
limit: 100,
},
});
analysisPackageElements = products;
const getVariant = userSpecificVariantLoader({ account });
const analysisPackagesWithVariant = analysisPackagesResponse.response.products
.reduce((acc, product) => {
const variant = getVariant({ product });
if (!variant) {
return acc;
}
return [
...acc,
{
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);

View File

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

View File

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

View File

@@ -1,14 +1,19 @@
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 (
<div className="grid grid-cols-3 gap-6">
{analysisPackages.length > 0 ? analysisPackages.map(
(product) => (
<SelectAnalysisPackage key={product.title} analysisPackage={product} countryCode={countryCode} />
(analysisPackage) => (
<SelectAnalysisPackage key={analysisPackage.title} analysisPackage={analysisPackage} countryCode={countryCode} />
)) : (
<h4>
<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) {
return [];
}