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>
);
}

View File

@@ -0,0 +1,78 @@
import { z } from 'zod';
const production = process.env.NODE_ENV === 'production';
const AppConfigSchema = z
.object({
name: z
.string({
description: `This is the name of your SaaS. Ex. "Makerkit"`,
required_error: `Please provide the variable NEXT_PUBLIC_PRODUCT_NAME`,
})
.min(1),
title: z
.string({
description: `This is the default title tag of your SaaS.`,
required_error: `Please provide the variable NEXT_PUBLIC_SITE_TITLE`,
})
.min(1),
description: z.string({
description: `This is the default description of your SaaS.`,
required_error: `Please provide the variable NEXT_PUBLIC_SITE_DESCRIPTION`,
}),
url: z
.string({
required_error: `Please provide the variable NEXT_PUBLIC_SITE_URL`,
})
.url({
message: `You are deploying a production build but have entered a NEXT_PUBLIC_SITE_URL variable using http instead of https. It is very likely that you have set the incorrect URL. The build will now fail to prevent you from from deploying a faulty configuration. Please provide the variable NEXT_PUBLIC_SITE_URL with a valid URL, such as: 'https://example.com'`,
}),
locale: z
.string({
description: `This is the default locale of your SaaS.`,
required_error: `Please provide the variable NEXT_PUBLIC_DEFAULT_LOCALE`,
})
.default('en'),
theme: z.enum(['light', 'dark', 'system']),
production: z.boolean(),
themeColor: z.string(),
themeColorDark: z.string(),
})
.refine(
(schema) => {
const isCI = process.env.NEXT_PUBLIC_CI;
if (isCI ?? !schema.production) {
return true;
}
return !schema.url.startsWith('http:');
},
{
message: `Please provide a valid HTTPS URL. Set the variable NEXT_PUBLIC_SITE_URL with a valid URL, such as: 'https://example.com'`,
path: ['url'],
},
)
.refine(
(schema) => {
return schema.themeColor !== schema.themeColorDark;
},
{
message: `Please provide different theme colors for light and dark themes.`,
path: ['themeColor'],
},
);
const appConfig = AppConfigSchema.parse({
name: process.env.NEXT_PUBLIC_PRODUCT_NAME,
title: process.env.NEXT_PUBLIC_SITE_TITLE,
description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION,
url: process.env.NEXT_PUBLIC_SITE_URL,
locale: process.env.NEXT_PUBLIC_DEFAULT_LOCALE,
theme: process.env.NEXT_PUBLIC_DEFAULT_THEME_MODE,
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR,
themeColorDark: process.env.NEXT_PUBLIC_THEME_COLOR_DARK,
production,
});
export default appConfig;

View File

@@ -0,0 +1,73 @@
import type { Provider } from '@supabase/supabase-js';
import { z } from 'zod';
const providers: z.ZodType<Provider> = getProviders();
const AuthConfigSchema = z.object({
captchaTokenSiteKey: z
.string({
description: 'The reCAPTCHA site key.',
})
.optional(),
displayTermsCheckbox: z
.boolean({
description: 'Whether to display the terms checkbox during sign-up.',
})
.optional(),
providers: z.object({
password: z.boolean({
description: 'Enable password authentication.',
}),
magicLink: z.boolean({
description: 'Enable magic link authentication.',
}),
oAuth: providers.array(),
}),
});
const authConfig = AuthConfigSchema.parse({
// NB: This is a public key, so it's safe to expose.
// Copy the value from the Supabase Dashboard.
captchaTokenSiteKey: process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY,
// whether to display the terms checkbox during sign-up
displayTermsCheckbox:
process.env.NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX === 'true',
// NB: Enable the providers below in the Supabase Console
// in your production project
providers: {
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
oAuth: ['google'],
},
} satisfies z.infer<typeof AuthConfigSchema>);
export default authConfig;
function getProviders() {
return z.enum([
'apple',
'azure',
'bitbucket',
'discord',
'facebook',
'figma',
'github',
'gitlab',
'google',
'kakao',
'keycloak',
'linkedin',
'linkedin_oidc',
'notion',
'slack',
'spotify',
'twitch',
'twitter',
'workos',
'zoom',
'fly',
]);
}

View File

@@ -0,0 +1,8 @@
/*
Replace this file with your own billing configuration file.
Copy it from billing.sample.config.ts and update the configuration to match your billing provider and products.
This file will never be overwritten by git updates
*/
import sampleSchema from './billing.sample.config';
export default sampleSchema;

View File

@@ -0,0 +1,148 @@
/**
* This is a sample billing configuration file. You should copy this file to `billing.config.ts` and then replace
* the configuration with your own billing provider and products.
*/
import { BillingProviderSchema, createBillingSchema } from '@kit/billing';
// The billing provider to use. This should be set in the environment variables
// and should match the provider in the database. We also add it here so we can validate
// your configuration against the selected provider at build time.
const provider = BillingProviderSchema.parse(
process.env.NEXT_PUBLIC_BILLING_PROVIDER,
);
export default createBillingSchema({
// also update config.billing_provider in the DB to match the selected
provider,
// products configuration
products: [
{
id: 'starter',
name: 'Starter',
description: 'The perfect plan to get started',
currency: 'USD',
badge: `Value`,
plans: [
{
name: 'Starter Monthly',
id: 'starter-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'Starter',
cost: 9.99,
type: 'flat' as const,
},
],
},
{
name: 'Starter Yearly',
id: 'starter-yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'starter-yearly',
name: 'Base',
cost: 99.99,
type: 'flat' as const,
},
],
},
],
features: ['Feature 1', 'Feature 2', 'Feature 3'],
},
{
id: 'pro',
name: 'Pro',
badge: `Popular`,
highlighted: true,
description: 'The perfect plan for professionals',
currency: 'USD',
plans: [
{
name: 'Pro Monthly',
id: 'pro-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1PGOAVI1i3VnbZTqc69xaypm',
name: 'Base',
cost: 19.99,
type: 'flat',
},
],
},
{
name: 'Pro Yearly',
id: 'pro-yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'price_pro_yearly',
name: 'Base',
cost: 199.99,
type: 'flat',
},
],
},
],
features: [
'Feature 1',
'Feature 2',
'Feature 3',
'Feature 4',
'Feature 5',
],
},
{
id: 'enterprise',
name: 'Enterprise',
description: 'The perfect plan for enterprises',
currency: 'USD',
plans: [
{
name: 'Enterprise Monthly',
id: 'enterprise-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_enterprise-monthly',
name: 'Base',
cost: 29.99,
type: 'flat',
},
],
},
{
name: 'Enterprise Yearly',
id: 'enterprise-yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'price_enterprise_yearly',
name: 'Base',
cost: 299.9,
type: 'flat',
},
],
},
],
features: [
'Feature 1',
'Feature 2',
'Feature 3',
'Feature 4',
'Feature 5',
'Feature 6',
'Feature 7',
],
},
],
});

View File

@@ -0,0 +1,112 @@
import { z } from 'zod';
type LanguagePriority = 'user' | 'application';
const FeatureFlagsSchema = z.object({
enableThemeToggle: z.boolean({
description: 'Enable theme toggle in the user interface.',
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_THEME_TOGGLE',
}),
enableAccountDeletion: z.boolean({
description: 'Enable personal account deletion.',
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION',
}),
enableTeamDeletion: z.boolean({
description: 'Enable team deletion.',
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION',
}),
enableTeamAccounts: z.boolean({
description: 'Enable team accounts.',
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS',
}),
enableTeamCreation: z.boolean({
description: 'Enable team creation.',
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION',
}),
enablePersonalAccountBilling: z.boolean({
description: 'Enable personal account billing.',
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING',
}),
enableTeamAccountBilling: z.boolean({
description: 'Enable team account billing.',
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING',
}),
languagePriority: z
.enum(['user', 'application'], {
required_error: 'Provide the variable NEXT_PUBLIC_LANGUAGE_PRIORITY',
description: `If set to user, use the user's preferred language. If set to application, use the application's default language.`,
})
.default('application'),
enableNotifications: z.boolean({
description: 'Enable notifications functionality',
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS',
}),
realtimeNotifications: z.boolean({
description: 'Enable realtime for the notifications functionality',
required_error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS',
}),
enableVersionUpdater: z.boolean({
description: 'Enable version updater',
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_VERSION_UPDATER',
}),
});
const featureFlagsConfig = FeatureFlagsSchema.parse({
enableThemeToggle: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_THEME_TOGGLE,
true,
),
enableAccountDeletion: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION,
false,
),
enableTeamDeletion: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION,
false,
),
enableTeamAccounts: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS,
true,
),
enableTeamCreation: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION,
true,
),
enablePersonalAccountBilling: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING,
false,
),
enableTeamAccountBilling: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING,
false,
),
languagePriority: process.env
.NEXT_PUBLIC_LANGUAGE_PRIORITY as LanguagePriority,
enableNotifications: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS,
true,
),
realtimeNotifications: getBoolean(
process.env.NEXT_PUBLIC_REALTIME_NOTIFICATIONS,
false,
),
enableVersionUpdater: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_VERSION_UPDATER,
false,
),
} satisfies z.infer<typeof FeatureFlagsSchema>);
export default featureFlagsConfig;
function getBoolean(value: unknown, defaultValue: boolean) {
if (typeof value === 'string') {
return value === 'true';
}
return defaultValue;
}

View File

@@ -0,0 +1,21 @@
import appConfig from './app.config';
import authConfig from './auth.config';
import billingConfig from './billing.config';
import featureFlagsConfig from './feature-flags.config';
import pathsConfig from './paths.config';
import { personalAccountNavigationConfig } from './personal-account-navigation.config';
import {
createPath,
getTeamAccountSidebarConfig,
} from './team-account-navigation.config';
export {
appConfig,
authConfig,
billingConfig,
createPath,
featureFlagsConfig,
getTeamAccountSidebarConfig,
pathsConfig,
personalAccountNavigationConfig,
};

View File

@@ -0,0 +1,81 @@
import { z } from 'zod';
const PathsSchema = z.object({
auth: z.object({
signIn: z.string().min(1),
signUp: z.string().min(1),
verifyMfa: z.string().min(1),
callback: z.string().min(1),
passwordReset: z.string().min(1),
passwordUpdate: z.string().min(1),
updateAccount: z.string().min(1),
updateAccountSuccess: z.string().min(1),
membershipConfirmation: z.string().min(1),
}),
app: z.object({
home: z.string().min(1),
selectPackage: z.string().min(1),
booking: z.string().min(1),
myOrders: z.string().min(1),
analysisResults: z.string().min(1),
orderAnalysisPackage: z.string().min(1),
orderAnalysis: z.string().min(1),
orderHealthAnalysis: z.string().min(1),
personalAccountSettings: z.string().min(1),
personalAccountBilling: z.string().min(1),
personalAccountBillingReturn: z.string().min(1),
accountHome: z.string().min(1),
accountSettings: z.string().min(1),
accountBilling: z.string().min(1),
accountMembers: z.string().min(1),
accountBillingReturn: z.string().min(1),
joinTeam: z.string().min(1),
admin: z.string().min(1),
doctor: z.string().min(1),
myJobs: z.string().min(1),
completedJobs: z.string().min(1),
openJobs: z.string().min(1),
analysisDetails: z.string().min(1),
}),
});
const pathsConfig = PathsSchema.parse({
auth: {
signIn: '/auth/sign-in',
signUp: '/auth/sign-up',
verifyMfa: '/auth/verify',
callback: '/auth/callback',
passwordReset: '/auth/password-reset',
passwordUpdate: '/update-password',
updateAccount: '/auth/update-account',
updateAccountSuccess: '/auth/update-account/success',
membershipConfirmation: '/auth/membership-confirmation',
},
app: {
home: '/home',
personalAccountSettings: '/home/settings',
personalAccountBilling: '/home/billing',
personalAccountBillingReturn: '/home/billing/return',
accountHome: '/home/[account]',
accountSettings: `/home/[account]/settings`,
accountBilling: `/home/[account]/billing`,
accountMembers: `/home/[account]/members`,
accountBillingReturn: `/home/[account]/billing/return`,
joinTeam: '/join',
selectPackage: '/select-package',
booking: '/home/booking',
orderAnalysisPackage: '/home/order-analysis-package',
myOrders: '/home/order',
analysisResults: '/home/analysis-results',
orderAnalysis: '/home/order-analysis',
orderHealthAnalysis: '/home/order-health-analysis',
doctor: '/doctor',
admin: '/admin',
myJobs: '/doctor/my-jobs',
completedJobs: '/doctor/completed-jobs',
openJobs: '/doctor/open-jobs',
analysisDetails: 'doctor/analysis',
},
} satisfies z.infer<typeof PathsSchema>);
export default pathsConfig;

View File

@@ -0,0 +1,71 @@
import {
FileLineChart,
HeartPulse,
LineChart,
MousePointerClick,
ShoppingCart,
Stethoscope,
TestTube2,
} from 'lucide-react';
import { z } from 'zod';
import { pathsConfig } from '@kit/shared/config';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
const iconClasses = 'w-4 stroke-[1.5px]';
const routes = [
{
children: [
{
label: 'common:routes.overview',
path: pathsConfig.app.home,
Icon: <LineChart className={iconClasses} />,
end: true,
},
{
label: 'common:routes.booking',
path: pathsConfig.app.booking,
Icon: <MousePointerClick className={iconClasses} />,
end: true,
},
{
label: 'common:routes.myOrders',
path: pathsConfig.app.myOrders,
Icon: <ShoppingCart className={iconClasses} />,
end: true,
},
{
label: 'common:routes.analysisResults',
path: pathsConfig.app.analysisResults,
Icon: <TestTube2 className={iconClasses} />,
end: true,
},
{
label: 'common:routes.orderAnalysisPackage',
path: pathsConfig.app.orderAnalysisPackage,
Icon: <HeartPulse className={iconClasses} />,
end: true,
},
{
label: 'common:routes.orderAnalysis',
path: pathsConfig.app.orderAnalysis,
Icon: <FileLineChart className={iconClasses} />,
end: true,
},
{
label: 'common:routes.orderHealthAnalysis',
path: pathsConfig.app.orderHealthAnalysis,
Icon: <Stethoscope className={iconClasses} />,
end: true,
},
],
},
] satisfies z.infer<typeof NavigationConfigSchema>['routes'];
export const personalAccountNavigationConfig = NavigationConfigSchema.parse({
routes,
style: 'custom',
sidebarCollapsed: false,
sidebarCollapsedStyle: 'icon',
});

View File

@@ -0,0 +1,56 @@
import { CreditCard, LayoutDashboard, Settings, Users } from 'lucide-react';
import { featureFlagsConfig, pathsConfig } from '@kit/shared/config';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
const iconClasses = 'w-4';
const getRoutes = (account: string) => [
{
label: 'common:routes.application',
children: [
{
label: 'common:routes.dashboard',
path: pathsConfig.app.accountHome.replace('[account]', account),
Icon: <LayoutDashboard className={iconClasses} />,
end: true,
},
],
},
{
label: 'common:routes.settings',
collapsible: false,
children: [
{
label: 'common:routes.settings',
path: createPath(pathsConfig.app.accountSettings, account),
Icon: <Settings className={iconClasses} />,
},
{
label: 'common:routes.members',
path: createPath(pathsConfig.app.accountMembers, account),
Icon: <Users className={iconClasses} />,
},
featureFlagsConfig.enableTeamAccountBilling
? {
label: 'common:routes.billing',
path: createPath(pathsConfig.app.accountBilling, account),
Icon: <CreditCard className={iconClasses} />,
}
: undefined,
].filter(Boolean),
},
];
export function getTeamAccountSidebarConfig(account: string) {
return NavigationConfigSchema.parse({
routes: getRoutes(account),
style: process.env.NEXT_PUBLIC_TEAM_NAVIGATION_STYLE,
sidebarCollapsed: process.env.NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED,
sidebarCollapsedStyle: process.env.NEXT_PUBLIC_SIDEBAR_COLLAPSED_STYLE,
});
}
export function createPath(path: string, account: string) {
return path.replace('[account]', account);
}

View File

@@ -1,3 +1,6 @@
import { format } from 'date-fns';
import Isikukood, { Gender } from 'isikukood';
/**
* Check if the code is running in a browser environment.
*/
@@ -21,3 +24,36 @@ export function formatCurrency(params: {
currency: params.currencyCode,
}).format(Number(params.value));
}
export function formatDateAndTime(date?: string) {
if (!date) return '-';
return format(date, 'dd.MM.yyyy HH:mm');
}
export function formatDate(date?: string) {
if (!date) return '-';
return format(date, 'dd.MM.yyyy');
}
export function getFullName(
firstName?: string | null,
lastName?: string | null,
) {
return [firstName ?? '', lastName ?? ''].join(' ');
}
export const getPersonParameters = (personalCode: string) => {
try {
const person = new Isikukood(personalCode);
return {
gender: person.getGender(),
dob: person.getBirthday(),
age: person.getAge(),
};
} catch (error) {
console.error(error);
return null;
}
};