MED-137: add doctor other jobs view (#55)

* add doctor jobs view

* change translation

* another translation change

* clean up

* add analaysis detail view to paths config

* translation

* merge fix

* fix path

* move components to shared

* refactor

* imports

* clean up
This commit is contained in:
Helena
2025-08-25 11:12:57 +03:00
committed by GitHub
parent ee86bb8829
commit 195af1db3d
156 changed files with 2823 additions and 364 deletions

View File

@@ -0,0 +1,104 @@
'use client';
import { useCallback, useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { analytics } from '@kit/analytics';
import {
AppEvent,
AppEventType,
ConsumerProvidedEventTypes,
useAppEvents,
} from '@kit/shared/events';
import { isBrowser } from '@kit/shared/utils';
type AnalyticsMapping<
T extends ConsumerProvidedEventTypes = NonNullable<unknown>,
> = {
[K in AppEventType<T>]?: (event: AppEvent<T, K>) => unknown;
};
/**
* Hook to subscribe to app events and map them to analytics actions
* @param mapping
*/
function useAnalyticsMapping<T extends ConsumerProvidedEventTypes>(
mapping: AnalyticsMapping<T>,
) {
const appEvents = useAppEvents<T>();
useEffect(() => {
const subscriptions = Object.entries(mapping).map(
([eventType, handler]) => {
appEvents.on(eventType as AppEventType<T>, handler);
return () => appEvents.off(eventType as AppEventType<T>, handler);
},
);
return () => {
subscriptions.forEach((unsubscribe) => unsubscribe());
};
}, [appEvents, mapping]);
}
/**
* Define a mapping of app events to analytics actions
* Add new mappings here to track new events in the analytics service from app events
*/
const analyticsMapping: AnalyticsMapping = {
'user.signedIn': (event) => {
const { userId, ...traits } = event.payload;
if (userId) {
return analytics.identify(userId, traits);
}
},
'user.signedUp': (event) => {
return analytics.trackEvent(event.type, event.payload);
},
'checkout.started': (event) => {
return analytics.trackEvent(event.type, event.payload);
},
'user.updated': (event) => {
return analytics.trackEvent(event.type, event.payload);
},
};
function AnalyticsProviderBrowser(props: React.PropsWithChildren) {
// Subscribe to app events and map them to analytics actions
useAnalyticsMapping(analyticsMapping);
// Report page views to the analytics service
useReportPageView(useCallback((url) => analytics.trackPageView(url), []));
// Render children
return props.children;
}
/**
* Provider for the analytics service
*/
export function AnalyticsProvider(props: React.PropsWithChildren) {
if (!isBrowser()) {
return props.children;
}
return <AnalyticsProviderBrowser>{props.children}</AnalyticsProviderBrowser>;
}
/**
* Hook to report page views to the analytics service
* @param reportAnalyticsFn
*/
function useReportPageView(reportAnalyticsFn: (url: string) => unknown) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
const url = [pathname, searchParams.toString()].filter(Boolean).join('?');
reportAnalyticsFn(url);
}, [pathname, reportAnalyticsFn, searchParams]);
}

View File

@@ -0,0 +1,36 @@
import Link from 'next/link';
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
function LogoImage({
className,
compact = false,
}: {
className?: string;
width?: number;
compact?: boolean;
}) {
return <MedReportLogo compact={compact} className={className} />;
}
export function AppLogo({
href,
label,
className,
compact = false,
}: {
href?: string | null;
className?: string;
label?: string;
compact?: boolean;
}) {
if (href === null) {
return <LogoImage className={className} compact={compact} />;
}
return (
<Link aria-label={label ?? 'Home Page'} href={href ?? '/'} prefetch={true}>
<LogoImage className={className} compact={compact} />
</Link>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import { useCallback } from 'react';
import type { AuthChangeEvent, Session } from '@supabase/supabase-js';
import { useMonitoring } from '@kit/monitoring/hooks';
import { useAppEvents } from '@kit/shared/events';
import { useAuthChangeListener } from '@kit/supabase/hooks/use-auth-change-listener';
import { pathsConfig } from '@kit/shared/config';
export function AuthProvider(props: React.PropsWithChildren) {
const dispatchEvent = useDispatchAppEventFromAuthEvent();
const onEvent = useCallback(
(event: AuthChangeEvent, session: Session | null) => {
dispatchEvent(event, session?.user.id, {
email: session?.user.email ?? '',
});
},
[dispatchEvent],
);
useAuthChangeListener({
appHomePath: pathsConfig.app.home,
onEvent,
});
return props.children;
}
function useDispatchAppEventFromAuthEvent() {
const { emit } = useAppEvents();
const monitoring = useMonitoring();
return useCallback(
(
type: AuthChangeEvent,
userId: string | undefined,
traits: Record<string, string> = {},
) => {
switch (type) {
case 'INITIAL_SESSION':
if (userId) {
emit({
type: 'user.signedIn',
payload: { userId, ...traits },
});
monitoring.identifyUser({ id: userId, ...traits });
}
break;
case 'SIGNED_IN':
if (userId) {
emit({
type: 'user.signedIn',
payload: { userId, ...traits },
});
monitoring.identifyUser({ id: userId, ...traits });
}
break;
case 'USER_UPDATED':
emit({
type: 'user.updated',
payload: { userId: userId!, ...traits },
});
break;
}
},
[emit, monitoring],
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import { useRouter } from 'next/navigation';
import { ArrowLeft } from '@/public/assets/arrow-left';
import { Trans } from '@kit/ui/trans';
export function BackButton({ onBack }: { onBack?: () => void }) {
const router = useRouter();
return (
<form
action={() => {
if (onBack) {
onBack();
} else {
router.back();
}
}}
>
<button className="absolute top-4 left-4 flex cursor-pointer flex-row items-center gap-3">
<div className="flex items-center justify-center rounded-sm border p-3">
<ArrowLeft />
</div>
<span className="text-sm">
<Trans i18nKey="common:goBack" />
</span>
</button>
</form>
);
}

View File

@@ -0,0 +1,52 @@
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { Trans } from '@kit/ui/trans';
export default function ConfirmationModal({
isOpen,
onClose,
onConfirm,
titleKey,
descriptionKey,
cancelKey = 'common:cancel',
confirmKey = 'common:confirm',
}: {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
titleKey: string;
descriptionKey: string;
cancelKey?: string;
confirmKey?: string;
}) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={titleKey} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={descriptionKey} />
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<Trans i18nKey={cancelKey} />
</Button>
<Button onClick={onConfirm}>
<Trans i18nKey={confirmKey} />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,69 @@
import Link from 'next/link';
import { signOutAction } from '@/lib/actions/sign-out';
import { hasEnvVars } from '@/utils/supabase/check-env-vars';
import { createClient } from '@/utils/supabase/server';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
export default async function AuthButton() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!hasEnvVars) {
return (
<>
<div className="flex items-center gap-4">
<div>
<Badge
variant={'default'}
className="pointer-events-none font-normal"
>
Please update .env.local file with anon key and url
</Badge>
</div>
<div className="flex gap-2">
<Button
asChild
size="sm"
variant={'outline'}
disabled
className="pointer-events-none cursor-none opacity-75"
>
<Link href="/sign-in">Sign in</Link>
</Button>
<Button
asChild
size="sm"
variant={'default'}
disabled
className="pointer-events-none cursor-none opacity-75"
>
<Link href="example/sign-up">Sign up</Link>
</Button>
</div>
</div>
</>
);
}
return user ? (
<div className="flex items-center gap-4">
Hey, {user.email}!
<form action={signOutAction}>
<Button type="submit" variant={'outline'}>
Sign out
</Button>
</form>
</div>
) : (
<div className="flex gap-2">
<Button asChild size="sm" variant={'outline'}>
<Link href="/sign-in">Sign in</Link>
</Button>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { cn } from '@kit/ui/utils';
import { MedReportSmallLogo } from '../../../../public/assets/med-report-small-logo';
export const MedReportLogo = ({
className,
compact = false,
}: {
className?: string;
compact?: boolean;
}) => (
<div className={cn('flex justify-center gap-2', className)}>
<MedReportSmallLogo />
{!compact && (
<span className="text-foreground text-lg font-semibold tracking-tighter">
MedReport
</span>
)}
</div>
);

View File

@@ -0,0 +1,31 @@
import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge';
import { cn } from '@kit/ui/utils';
export const PackageHeader = ({
title,
tagColor,
analysesNr,
language,
price,
}: {
title: string;
tagColor: string;
analysesNr: string;
language: string;
price: string | number;
}) => {
return (
<div className="space-y-1 text-center">
<p className="text-sm sm:text-lg sm:font-medium">{title}</p>
<h2 className="text-xl sm:text-4xl">
{formatCurrency({
currencyCode: 'eur',
locale: language,
value: price,
})}
</h2>
<Badge className={cn('text-xs', tagColor)}>{analysesNr}</Badge>
</div>
);
};

View File

@@ -0,0 +1,61 @@
'use client';
import type { User } from '@supabase/supabase-js';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { ApplicationRole } from '@kit/accounts/types/accounts';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useUser } from '@kit/supabase/hooks/use-user';
import { pathsConfig, featureFlagsConfig } from '@kit/shared/config';
const paths = {
home: pathsConfig.app.home,
admin: pathsConfig.app.admin,
doctor: pathsConfig.app.doctor,
personalAccountSettings: pathsConfig.app.personalAccountSettings,
};
const features = {
enableThemeToggle: featureFlagsConfig.enableThemeToggle,
};
export function ProfileAccountDropdownContainer(props: {
user?: User;
showProfileName?: boolean;
account?: {
id: string | null;
name: string | null;
picture_url: string | null;
application_role: ApplicationRole | null;
};
accounts: {
label: string | null;
value: string | null;
image?: string | null;
application_role: ApplicationRole | null;
}[];
}) {
const signOut = useSignOut();
const user = useUser(props.user);
const userData = user.data;
if (!userData) {
return null;
}
return (
<PersonalAccountDropdown
className={'w-full'}
paths={paths}
features={features}
user={userData}
account={props.account}
accounts={props.accounts}
signOutRequested={() => signOut.mutateAsync()}
showProfileName={props.showProfileName}
/>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export function ReactQueryProvider(props: React.PropsWithChildren) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { useMemo } from 'react';
import dynamic from 'next/dynamic';
import { ThemeProvider } from 'next-themes';
import { CaptchaProvider } from '@kit/auth/captcha/client';
import { I18nProvider } from '@kit/i18n/provider';
import { MonitoringProvider } from '@kit/monitoring/components';
import { AnalyticsProvider } from '@kit/shared/components/analytics-provider';
import { AuthProvider } from '@kit/shared/components/auth-provider';
import { appConfig, authConfig, featureFlagsConfig } from '@kit/shared/config';
import { AppEventsProvider } from '@kit/shared/events';
import { If } from '@kit/ui/if';
import { VersionUpdater } from '@kit/ui/version-updater';
import { i18nResolver } from '../../../../lib/i18n/i18n.resolver';
import { getI18nSettings } from '../../../../lib/i18n/i18n.settings';
import { ReactQueryProvider } from './react-query-provider';
const captchaSiteKey = authConfig.captchaTokenSiteKey;
const CaptchaTokenSetter = dynamic(async () => {
if (!captchaSiteKey) {
return Promise.resolve(() => null);
}
const { CaptchaTokenSetter } = await import('@kit/auth/captcha/client');
return {
default: CaptchaTokenSetter,
};
});
type RootProvidersProps = React.PropsWithChildren<{
// The language to use for the app (optional)
lang?: string;
// The theme (light or dark or system) (optional)
theme?: string;
// The CSP nonce to pass to scripts (optional)
nonce?: string;
}>;
export function RootProviders({
lang,
theme = appConfig.theme,
nonce,
children,
}: RootProvidersProps) {
const i18nSettings = useMemo(() => getI18nSettings(lang), [lang]);
return (
<MonitoringProvider>
<AppEventsProvider>
<AnalyticsProvider>
<ReactQueryProvider>
<I18nProvider settings={i18nSettings} resolver={i18nResolver}>
<CaptchaProvider>
<CaptchaTokenSetter siteKey={captchaSiteKey} />
<AuthProvider>
<ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme={theme}
enableColorScheme={false}
nonce={nonce}
>
{children}
</ThemeProvider>
</AuthProvider>
</CaptchaProvider>
<If condition={featureFlagsConfig.enableVersionUpdater}>
<VersionUpdater />
</If>
</I18nProvider>
</ReactQueryProvider>
</AnalyticsProvider>
</AppEventsProvider>
</MonitoringProvider>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { StoreProduct, StoreProductVariant } from '@medusajs/types';
import { Button } from '@medusajs/ui';
import { useTranslation } from 'react-i18next';
import {
Card,
CardContent,
CardDescription,
CardFooter,
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';
export interface IAnalysisPackage {
titleKey: string;
price: number;
tagColor: string;
descriptionKey: string;
}
export default function SelectAnalysisPackage({
analysisPackage,
countryCode,
}: {
analysisPackage: StoreProduct;
countryCode: string;
}) {
const router = useRouter();
const {
t,
i18n: { language },
} = useTranslation();
const [isAddingToCart, setIsAddingToCart] = useState(false);
const handleSelect = async (selectedVariant: StoreProductVariant) => {
if (!selectedVariant?.id) return null;
setIsAddingToCart(true);
await handleAddToCart({
selectedVariant,
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}>
<CardHeader className="relative">
{description && (
<ButtonTooltip
content={description}
className="absolute top-5 right-5 z-10"
/>
)}
<Image
src="/assets/card-image.png"
alt="background"
width={326}
height={195}
className="max-h-48 w-full opacity-10"
/>
</CardHeader>
<CardContent className="space-y-1 text-center">
<PackageHeader
title={t(titleKey)}
tagColor="bg-cyan"
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
language={language}
price={price}
/>
<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>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,19 @@
import { Trans } from '@kit/ui/trans';
import { StoreProduct } from '@medusajs/types';
import SelectAnalysisPackage from './select-analysis-package';
export default function SelectAnalysisPackages({ analysisPackages, countryCode }: { analysisPackages: StoreProduct[], 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} />
)) : (
<h4>
<Trans i18nKey="order-analysis-package:noPackagesAvailable" />
</h4>
)}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
export default function TableSkeleton({
rows = 1,
cols = 7,
}: {
rows?: number;
cols?: number;
}) {
return (
<Table className="w-full border-separate animate-pulse rounded-lg border">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
{Array.from({ length: cols }).map((_, i) => (
<TableHead key={i} className="p-2">
<div className="h-4 w-22 rounded bg-gray-200"></div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rows }).map((_, r) => (
<TableRow key={r} className="border-t border-gray-200">
{Array.from({ length: cols }).map((_, c) => (
<TableCell key={c}>
<div className="h-4 w-22 rounded bg-gray-200"></div>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,31 @@
import { Info } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
export function ButtonTooltip({
content,
className,
}: {
content?: string;
className?: string;
}) {
if (!content) return null;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button className={className} variant="outline" size="icon">
<Info className="size-4 cursor-pointer" />
</Button>
</TooltipTrigger>
<TooltipContent className='sm:max-w-[30vw] sm:leading-4'>{content}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,30 @@
import { JSX } from 'react';
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>
{icon || <Info className="size-4 cursor-pointer" />}
</TooltipTrigger>
<TooltipContent>{content}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,33 @@
import React, { JSX, ReactNode } from 'react';
import { cn } from '@kit/ui/utils';
export type SearchProps = React.InputHTMLAttributes<HTMLInputElement> & {
startElement?: string | JSX.Element;
className?: string;
};
const Search = React.forwardRef<HTMLInputElement, SearchProps>(
({ className, startElement, ...props }, ref) => {
return (
<div
className={cn(
'border-input ring-offset-background focus-within:ring-ring flex h-10 items-center rounded-md border bg-white pl-3 text-sm focus-within:ring-1 focus-within:ring-offset-2',
className,
)}
>
{!!startElement && startElement}
<input
{...props}
type="search"
ref={ref}
className="placeholder:text-muted-foreground w-full p-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
);
},
);
Search.displayName = 'Search';
export { Search };

View File

@@ -0,0 +1,23 @@
"use client";
import { Button } from "@kit/ui/button";
import { type ComponentProps } from "react";
import { useFormStatus } from "react-dom";
type Props = ComponentProps<typeof Button> & {
pendingText?: string;
};
export function SubmitButton({
children,
pendingText = "Submitting...",
...props
}: Props) {
const { pending } = useFormStatus();
return (
<Button type="submit" aria-disabled={pending} {...props}>
{pending ? pendingText : children}
</Button>
);
}