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:
104
packages/shared/src/components/analytics-provider.tsx
Normal file
104
packages/shared/src/components/analytics-provider.tsx
Normal 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]);
|
||||
}
|
||||
36
packages/shared/src/components/app-logo.tsx
Normal file
36
packages/shared/src/components/app-logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
packages/shared/src/components/auth-provider.tsx
Normal file
80
packages/shared/src/components/auth-provider.tsx
Normal 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],
|
||||
);
|
||||
}
|
||||
31
packages/shared/src/components/back-button.tsx
Normal file
31
packages/shared/src/components/back-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
packages/shared/src/components/confirmation-modal.tsx
Normal file
52
packages/shared/src/components/confirmation-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
packages/shared/src/components/header-auth.tsx
Normal file
69
packages/shared/src/components/header-auth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
packages/shared/src/components/med-report-logo.tsx
Normal file
20
packages/shared/src/components/med-report-logo.tsx
Normal 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>
|
||||
);
|
||||
31
packages/shared/src/components/package-header.tsx
Normal file
31
packages/shared/src/components/package-header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
packages/shared/src/components/react-query-provider.tsx
Normal file
26
packages/shared/src/components/react-query-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
packages/shared/src/components/root-providers.tsx
Normal file
87
packages/shared/src/components/root-providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
packages/shared/src/components/select-analysis-package.tsx
Normal file
114
packages/shared/src/components/select-analysis-package.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
packages/shared/src/components/select-analysis-packages.tsx
Normal file
19
packages/shared/src/components/select-analysis-packages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
packages/shared/src/components/table-skeleton.tsx
Normal file
41
packages/shared/src/components/table-skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
packages/shared/src/components/ui/button-tooltip.tsx
Normal file
31
packages/shared/src/components/ui/button-tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
packages/shared/src/components/ui/info-tooltip.tsx
Normal file
30
packages/shared/src/components/ui/info-tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
packages/shared/src/components/ui/search.tsx
Normal file
33
packages/shared/src/components/ui/search.tsx
Normal 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 };
|
||||
23
packages/shared/src/components/ui/submit-button.tsx
Normal file
23
packages/shared/src/components/ui/submit-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
packages/shared/src/config/app.config.ts
Normal file
78
packages/shared/src/config/app.config.ts
Normal 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;
|
||||
73
packages/shared/src/config/auth.config.ts
Normal file
73
packages/shared/src/config/auth.config.ts
Normal 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',
|
||||
]);
|
||||
}
|
||||
8
packages/shared/src/config/billing.config.ts
Normal file
8
packages/shared/src/config/billing.config.ts
Normal 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;
|
||||
148
packages/shared/src/config/billing.sample.config.ts
Normal file
148
packages/shared/src/config/billing.sample.config.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
112
packages/shared/src/config/feature-flags.config.ts
Normal file
112
packages/shared/src/config/feature-flags.config.ts
Normal 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;
|
||||
}
|
||||
21
packages/shared/src/config/index.ts
Normal file
21
packages/shared/src/config/index.ts
Normal 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,
|
||||
};
|
||||
81
packages/shared/src/config/paths.config.ts
Normal file
81
packages/shared/src/config/paths.config.ts
Normal 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;
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user