27 Commits

Author SHA1 Message Date
ab0834149d wip 2025-07-18 16:10:13 +03:00
5487242bbe feat(MED-100): db types 2025-07-17 12:55:59 +03:00
7ccc45ce77 feat(MED-100): show toast on delete 2025-07-17 10:44:05 +03:00
25b4e06b89 feat(MED-100): improve styles 2025-07-17 10:19:36 +03:00
55869ea16f feat(MED-100): create medusa store account for user 2025-07-17 10:19:27 +03:00
736194bb0b feat(MED-100): updateaccountform should be prefilled 2025-07-17 10:17:50 +03:00
6426e2a79b feat(MED-100): update cart checkout flow and views 2025-07-17 10:16:52 +03:00
ea3fb22f1d feat(MED-122): delete unused translations 2025-07-17 10:10:08 +03:00
02bb9f7d34 feat(MED-99): use montonio api and webhook 2025-07-17 10:09:55 +03:00
00b079e170 feat(MED-122): fix error in case of cart without shipping 2025-07-17 10:05:55 +03:00
1d0808018b feat(MED-122): don't use math.random on ssr 2025-07-17 10:01:32 +03:00
7b71da3825 feat(MED-122): use <Trans> instead of t() 2025-07-10 13:24:03 +03:00
ad213dd4f8 feat(MED-122): consistently format name in titlecase 2025-07-10 13:19:28 +03:00
0a0b1f0dee feat(MED-122): update current user account loader 2025-07-10 13:19:01 +03:00
bbcf0b6d83 feat(MED-122): update analysis packages page, pageheader 2025-07-10 11:36:57 +03:00
danelkungla
0af3823148 FIX Build
Fix build
2025-07-09 14:02:53 +03:00
Danel Kungla
23b54bb4f4 Merge branch 'main' into FIX-BUILD 2025-07-09 14:02:10 +03:00
Danel Kungla
c5ddccc15d fix build 2025-07-09 14:01:43 +03:00
danelkungla
023bc897c2 MED-63: change api to medreport schema
MED-63
* membership confirmation flow
* update schema public -> medreport
2025-07-09 13:40:28 +03:00
Danel Kungla
d9198a8a12 add medreport schema 2025-07-09 13:31:37 +03:00
Danel Kungla
0b8fadb771 remove medreport product migration and related constraints 2025-07-09 10:01:39 +03:00
Danel Kungla
9371ff7710 feat: update API and database structure for membership confirmation and wallet integration
- Refactor API calls to use 'medreport' schema for membership confirmation and account updates
- Enhance success notification component to include wallet balance and expiration details
- Modify database types to reflect new 'medreport' schema and add product-related tables
- Update localization files to support new wallet messages
- Adjust SQL migration scripts for proper schema references and function definitions
2025-07-09 10:01:12 +03:00
Danel Kungla
4f36f9c037 refactor: clean up imports and enhance error logging in user workspace and team invitations actions 2025-07-08 18:34:21 +03:00
Danel Kungla
29ff8cb512 fix pnpm-lock 2025-07-08 16:06:39 +03:00
Danel Kungla
c0a5238e19 Merge branch 'main' into MED-63 2025-07-08 16:06:27 +03:00
danelkungla
7bf5dd8899 Merge pull request #31 from MR-medreport/B2B-26
B2B-26: move selfservice tables to medreport schema and add medusa store
2025-07-08 15:56:30 +03:00
Danel Kungla
2e62e4b0eb move selfservice tables to medreport schema
add base medusa store frontend
2025-07-07 13:46:22 +03:00
384 changed files with 28526 additions and 1231 deletions

23
.env
View File

@@ -51,4 +51,25 @@ LOGGER=pino
NEXT_PUBLIC_DEFAULT_LOCALE=et
NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
#### MEDUSA
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_0ec86252438b38ce18d5601f7877e4395d7e0a6afa8687dfea8d37af33015633
#MEDUSA_BACKEND_URL=http://5.181.51.38:9000
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_e23a820689a07d55aa0a0ad187268559f5d6288ecb0768ff4520516285bdef84
MEDUSA_BACKEND_URL=http://localhost:9000
# NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_068d930c33fea53608a410d84a51935f6ce2ccec5bef8e0ecf75eaee602ac486
# MEDUSA_BACKEND_URL=https://backoffice-test.medreport.ee:443
#### MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
#### SUPABASE
# NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
# SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NjUyODEyMywiZXhwIjoyMDYyMTA0MTIzfQ.KVcnkZ21Pd0XkJho23dZqFHawVTLQqfvF7l2RxsELLk
NEXT_PUBLIC_SUPABASE_URL=http://5.181.51.38:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU

View File

@@ -2,7 +2,7 @@
# These values are only used when running the app in development mode.
# SUPABASE
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_URL=http://5.181.51.38:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU

View File

@@ -16,4 +16,10 @@ EMAIL_USER= # refer to your email provider's documentation
EMAIL_PASSWORD= # refer to your email provider's documentation
EMAIL_HOST= # refer to your email provider's documentation
EMAIL_PORT= # or 465 for SSL
EMAIL_TLS= # or false for SSL (see provider documentation)
EMAIL_TLS= # or false for SSL (see provider documentation)
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
MONTONIO_API_URL=https://sandbox-stargate.montonio.com

View File

@@ -39,6 +39,8 @@ pnpm clean
pnpm i
```
if you get missing dependency error do `pnpm i --force`
## Adding new dependency
```bash
@@ -71,6 +73,12 @@ To update database types run:
npm run supabase:typegen:app
```
## Medusa store
To get medusa store working you need to update the env's to your running medusa app and migrate the tables from medusa project to your supabase project
You can get `NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY` from your medusa app settings
## Super admin
To access admin pages follow these steps:

View File

@@ -0,0 +1,89 @@
'use client';
import { useRouter } from 'next/navigation';
import { SubmitButton } from '@/components/ui/submit-button';
import { sendCompanyOfferEmail } from '@/lib/services/mailer.service';
import { CompanySubmitData } from '@/lib/types/company';
import { companyOfferSchema } from '@/lib/validations/company-offer.schema';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { FormItem } from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Trans } from '@kit/ui/trans';
const CompanyOfferForm = () => {
const router = useRouter();
const language = useTranslation().i18n.language;
const {
register,
handleSubmit,
formState: { isValid, isSubmitting },
} = useForm({
resolver: zodResolver(companyOfferSchema),
mode: 'onChange',
});
const onSubmit = async (data: CompanySubmitData) => {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined) formData.append(key, value);
});
try {
sendCompanyOfferEmail(data, language)
.then(() => router.push('/company-offer/success'))
.catch((error) => alert('error: ' + error));
} catch (err: unknown) {
if (err instanceof Error) {
console.warn('Server validation error: ' + err.message);
}
console.warn('Server validation error: ', err);
}
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className="flex flex-col gap-6 px-6 pt-8 text-left"
>
<FormItem>
<Label>
<Trans i18nKey={'common:formField:companyName'} />
</Label>
<Input {...register('companyName')} />
</FormItem>
<FormItem>
<Label>
<Trans i18nKey={'common:formField:contactPerson'} />
</Label>
<Input {...register('contactPerson')} />
</FormItem>
<FormItem>
<Label>
<Trans i18nKey={'common:formField:email'} />
</Label>
<Input type="email" {...register('email')}></Input>
</FormItem>
<FormItem>
<Label>
<Trans i18nKey={'common:formField:phone'} />
</Label>
<Input type="tel" {...register('phone')} />
</FormItem>
<SubmitButton
disabled={!isValid || isSubmitting}
pendingText="Saatmine..."
type="submit"
>
<Trans i18nKey={'account:requestCompanyAccount:button'} />
</SubmitButton>
</form>
);
};
export default CompanyOfferForm;

View File

@@ -1,53 +1,13 @@
'use client';
import React from 'react';
import { useRouter } from 'next/navigation';
import { MedReportLogo } from '@/components/med-report-logo';
import { SubmitButton } from '@/components/ui/submit-button';
import { sendCompanyOfferEmail } from '@/lib/services/mailer.service';
import { CompanySubmitData } from '@/lib/types/company';
import { companyOfferSchema } from '@/lib/validations/company-offer.schema';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { withI18n } from '@/lib/i18n/with-i18n';
import { FormItem } from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Trans } from '@kit/ui/trans';
export default function CompanyOffer() {
const router = useRouter();
const {
register,
handleSubmit,
formState: { isValid, isSubmitting },
} = useForm({
resolver: zodResolver(companyOfferSchema),
mode: 'onChange',
});
const language = useTranslation().i18n.language;
const onSubmit = async (data: CompanySubmitData) => {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined) formData.append(key, value);
});
try {
sendCompanyOfferEmail(data, language)
.then(() => router.push('/company-offer/success'))
.catch((error) => alert('error: ' + error));
} catch (err: unknown) {
if (err instanceof Error) {
console.warn('Server validation error: ' + err.message);
}
console.warn('Server validation error: ', err);
}
};
import CompanyOfferForm from './_components/company-offer-form';
function CompanyOffer() {
return (
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
<div className="flex w-1/2 flex-col px-12 py-14 text-center">
@@ -58,45 +18,11 @@ export default function CompanyOffer() {
<p className="text-muted-foreground pt-2 text-sm">
<Trans i18nKey={'account:requestCompanyAccount:description'} />
</p>
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className="flex flex-col gap-6 px-6 pt-8 text-left"
>
<FormItem>
<Label>
<Trans i18nKey={'common:formField:companyName'} />
</Label>
<Input {...register('companyName')} />
</FormItem>
<FormItem>
<Label>
<Trans i18nKey={'common:formField:contactPerson'} />
</Label>
<Input {...register('contactPerson')} />
</FormItem>
<FormItem>
<Label>
<Trans i18nKey={'common:formField:email'} />
</Label>
<Input type="email" {...register('email')}></Input>
</FormItem>
<FormItem>
<Label>
<Trans i18nKey={'common:formField:phone'} />
</Label>
<Input type="tel" {...register('phone')} />
</FormItem>
<SubmitButton
disabled={!isValid || isSubmitting}
pendingText="Saatmine..."
type="submit"
>
<Trans i18nKey={'account:requestCompanyAccount:button'} />
</SubmitButton>
</form>
<CompanyOfferForm />
</div>
<div className="w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat"></div>
</div>
);
}
export default withI18n(CompanyOffer);

View File

@@ -34,6 +34,7 @@ async function accountLoader(id: string) {
const client = getSupabaseServerClient();
const { data, error } = await client
.schema('medreport')
.from('accounts')
.select('*, memberships: accounts_memberships (*)')
.eq('id', id)

View File

@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getLogger } from '@kit/shared/logger';
interface MontonioOrderToken {
uuid: string;
accessKey: string;
merchantReference: string;
merchantReferenceDisplay: string;
paymentStatus:
| 'PAID'
| 'FAILED'
| 'CANCELLED'
| 'PENDING'
| 'EXPIRED'
| 'REFUNDED';
paymentMethod: string;
grandTotal: number;
currency: string;
senderIban?: string;
senderName?: string;
paymentProviderName?: string;
paymentLinkUuid: string;
iat: number;
exp: number;
}
const BodySchema = z.object({
token: z.string(),
});
export const POST = enhanceRouteHandler(
async ({ request }) => {
const logger = await getLogger();
const body = await request.json();
const namespace = 'montonio.verify-token';
const activeCartId = request.cookies.get('_medusa_cart_id')?.value;
console.info('cart id', activeCartId);
try {
const { token } = BodySchema.parse(body);
const secretKey = process.env.MONTONIO_SECRET_KEY as string;
if (!secretKey) {
logger.error(
{
name: namespace,
},
`Missing MONTONIO_SECRET_KEY`,
);
throw new Error('Server misconfiguration.');
}
const decoded = jwt.verify(token, secretKey, {
algorithms: ['HS256'],
}) as MontonioOrderToken;
const [, cartId] = decoded.merchantReferenceDisplay.split(':');
console.info('active cart id parsed', {cartId, activeCartId, decoded:decoded.merchantReferenceDisplay});
if (cartId !== activeCartId) {
throw new Error('Invalid cart id');
}
logger.info(
{
name: namespace,
status: decoded.paymentStatus,
orderId: decoded.uuid,
},
`Successfully verified Montonio token.`,
);
return NextResponse.json({
status: decoded.paymentStatus,
});
} catch (error) {
logger.error(
{
name: namespace,
error,
},
`Failed to verify Montonio token`,
);
const message = error instanceof Error ? error.message : 'Invalid token';
return NextResponse.json(
{
error: message,
},
{
status: 400,
},
);
}
},
{
auth: false,
},
);

View File

@@ -0,0 +1,34 @@
'use client';
import React from 'react';
import pathsConfig from '@/config/paths.config';
import { useTranslation } from 'react-i18next';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { SuccessNotification } from '@kit/notifications/components';
const MembershipConfirmationNotification: React.FC<{
userId: string;
}> = ({ userId }) => {
const { t } = useTranslation();
const { data: accountData } = usePersonalAccountData(userId);
return (
<SuccessNotification
showLogo={false}
title={t('account:membershipConfirmation:successTitle', {
firstName: accountData?.name,
lastName: accountData?.last_name,
})}
descriptionKey="account:membershipConfirmation:successDescription"
buttonProps={{
buttonTitleKey: 'account:membershipConfirmation:successButton',
href: pathsConfig.app.selectPackage,
}}
/>
);
};
export default MembershipConfirmationNotification;

View File

@@ -8,4 +8,4 @@ async function SiteLayout(props: React.PropsWithChildren) {
);
}
export default withI18n(SiteLayout);
export default SiteLayout;

View File

@@ -1,16 +1,14 @@
import { redirect } from 'next/navigation';
import pathsConfig from '@/config/paths.config';
import { useTranslation } from 'react-i18next';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { SuccessNotification } from '@kit/notifications/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { withI18n } from '~/lib/i18n/with-i18n';
import MembershipConfirmationNotification from './_components/membership-confirmation-notification';
async function UpdateAccountSuccess() {
const { t } = useTranslation('account');
const client = getSupabaseServerClient();
const {
@@ -21,26 +19,7 @@ async function UpdateAccountSuccess() {
redirect(pathsConfig.app.home);
}
const { data: accountData } = usePersonalAccountData(user.id);
if (!accountData?.id) {
redirect(pathsConfig.app.home);
}
return (
<SuccessNotification
showLogo={false}
title={t('account:membershipConfirmation:successTitle', {
firstName: accountData?.name,
lastName: accountData?.last_name,
})}
descriptionKey="account:membershipConfirmation:successDescription"
buttonProps={{
buttonTitleKey: 'account:membershipConfirmation:successButton',
href: pathsConfig.app.selectPackage,
}}
/>
);
return <MembershipConfirmationNotification userId={user.id} />;
}
export default withI18n(UpdateAccountSuccess);

View File

@@ -26,7 +26,7 @@ async function getSupabaseHealthCheck() {
try {
const client = getSupabaseServerAdminClient();
const { error } = await client.rpc('is_set', {
const { error } = await client.schema('medreport').rpc('is_set', {
field_name: 'billing_provider',
});

View File

@@ -0,0 +1,34 @@
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
import OrderCards from '../../_components/order-cards';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
const title = i18n.t('booking:title');
return {
title,
};
};
function BookingPage() {
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'booking:title'} />}
description={<Trans i18nKey={'booking:description'} />}
/>
<PageBody>
<OrderCards />
</PageBody>
</>
);
}
export default withI18n(BookingPage);

View File

@@ -0,0 +1,5 @@
import SkeletonCartPage from '~/medusa/modules/skeletons/templates/skeleton-cart-page';
export default function Loading() {
return <SkeletonCartPage />;
}

View File

@@ -0,0 +1,23 @@
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { MontonioCheckoutCallback } from '../../../../_components/cart/montonio-checkout-callback';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { Trans } from '@kit/ui/trans';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
return {
title: t('cart:montonioCallback.title'),
};
}
export default async function MontonioCheckoutCallbackPage() {
return (
<div className={'flex h-full flex-1 flex-col'}>
<PageHeader title={<Trans i18nKey="cart:montonioCallback.title" />} />
<PageBody>
<MontonioCheckoutCallback />
</PageBody>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { notFound } from 'next/navigation';
import { retrieveCart } from '~/medusa/lib/data/cart';
import Cart from '../../_components/cart';
import { listCollections } from '@lib/data';
import CartTimer from '../../_components/cart/cart-timer';
import { Trans } from '@kit/ui/trans';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
return {
title: t('cart:title'),
};
}
export default async function CartPage() {
const cart = await retrieveCart().catch((error) => {
console.error(error);
return notFound();
});
const { collections } = await listCollections({
limit: "100",
});
const analysisPackagesCollection = collections.find(({ handle }) => handle === 'analysis-packages');
const analysisPackages = analysisPackagesCollection && cart?.items
? cart.items.filter((item) => item.product?.collection_id === analysisPackagesCollection.id)
: [];
const otherItems = cart?.items?.filter((item) => item.product?.collection_id !== analysisPackagesCollection?.id) ?? [];
const otherItemsSorted = otherItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1);
const item = otherItemsSorted[0];
return (
<PageBody>
<PageHeader title={<Trans i18nKey="cart:title" />}>
{item && item.updated_at && <CartTimer cartItem={item} />}
</PageHeader>
<Cart cart={cart} analysisPackages={analysisPackages} otherItems={otherItems} />
</PageBody>
);
}

View File

@@ -17,6 +17,7 @@ import { HomeMenuNavigation } from '../_components/home-menu-navigation';
import { HomeMobileNavigation } from '../_components/home-mobile-navigation';
import { HomeSidebar } from '../_components/home-sidebar';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
import { retrieveCart } from '@lib/data';
function UserHomeLayout({ children }: React.PropsWithChildren) {
const state = use(getLayoutState());
@@ -55,12 +56,13 @@ function SidebarLayout({ children }: React.PropsWithChildren) {
function HeaderLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
const cart = use(retrieveCart());
return (
<UserWorkspaceContextProvider value={workspace}>
<Page style={'header'}>
<PageNavigation>
<HomeMenuNavigation workspace={workspace} />
<HomeMenuNavigation workspace={workspace} cart={cart} />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>

View File

@@ -0,0 +1,46 @@
import { Scale } from 'lucide-react';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Button } from '@kit/ui/button';
import SelectAnalysisPackages from '@/components/select-analysis-packages';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import ComparePackagesModal from '../../_components/compare-packages-modal';
import { loadAnalysisPackages } from '../../_lib/server/load-analysis-packages';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
const title = i18n.t('order-analysis-package:title');
return {
title,
};
};
async function OrderAnalysisPackagePage() {
const { analysisPackages, countryCode } = await loadAnalysisPackages();
return (
<PageBody>
<div className="space-y-3 text-center">
<h3>
<Trans i18nKey={'marketing:selectPackage'} />
</h3>
<ComparePackagesModal
analysisPackages={analysisPackages}
triggerElement={
<Button variant="secondary" className="gap-2">
<Trans i18nKey={'marketing:comparePackages'} />
<Scale className="size-4 stroke-[1.5px]" />
</Button>
}
/>
</div>
<SelectAnalysisPackages analysisPackages={analysisPackages} countryCode={countryCode} />
</PageBody>
);
}
export default withI18n(OrderAnalysisPackagePage);

View File

@@ -0,0 +1,28 @@
import { notFound } from 'next/navigation';
import { retrieveOrder } from '~/medusa/lib/data/orders';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import OrderCompleted from '@/app/home/(user)/_components/order/order-completed';
type Props = {
params: Promise<{ orderId: string }>;
};
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
return {
title: t('cart:orderConfirmed.title'),
};
}
export default async function OrderConfirmedPage(props: Props) {
const params = await props.params;
const order = await retrieveOrder(params.orderId).catch(() => null);
if (!order) {
return notFound();
}
return <OrderCompleted order={order} />;
}

View File

@@ -1,14 +1,13 @@
import { Trans } from '@kit/ui/trans';
import { redirect } from 'next/navigation';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { toTitleCase } from '@/lib/utils';
import Dashboard from '../_components/dashboard';
// local imports
import { HomeLayoutPageHeader } from '../_components/home-page-header';
import { use } from 'react';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
import { PageBody } from '@kit/ui/page';
import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
@@ -19,15 +18,24 @@ export const generateMetadata = async () => {
};
};
function UserHomePage() {
const { tempVisibleAccounts } = use(loadUserWorkspace());
async function UserHomePage() {
const account = await loadCurrentUserAccount();
if (!account) {
redirect('/');
}
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'common:routes.home'} />}
description={<></>}
<PageHeader title={
<>
<Trans i18nKey={'common:welcome'} />
{account.name ? `, ${toTitleCase(account.name)}` : ''}
</>
}
description={
<Trans i18nKey={'dashboard:recentlyCheckedDescription'} />
}
/>
<PageBody>
<Dashboard />
</PageBody>

View File

@@ -0,0 +1,48 @@
"use client";
import { Trash } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from 'sonner';
import { deleteLineItem } from "@lib/data/cart";
import { Spinner } from "@medusajs/icons";
const CartItemDelete = ({
id,
children,
}: {
id: string;
children?: React.ReactNode;
}) => {
const [isDeleting, setIsDeleting] = useState(false);
const { t } = useTranslation();
const handleDelete = async () => {
setIsDeleting(true);
const promise = async () => {
await deleteLineItem(id);
};
toast.promise(promise, {
success: t(`cart:items.delete.success`),
loading: t(`cart:items.delete.loading`),
error: t(`cart:items.delete.error`),
});
};
return (
<div className="flex items-center justify-between text-small-regular">
<button
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer"
onClick={() => handleDelete()}
>
{isDeleting ? <Spinner className="animate-spin" /> : <Trash />}
<span>{children}</span>
</button>
</div>
);
};
export default CartItemDelete;

View File

@@ -0,0 +1,56 @@
"use client"
import { HttpTypes } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import {
TableCell,
TableRow,
} from '@kit/ui/table';
import { formatCurrency } from "@/packages/shared/src/utils"
import CartItemDelete from "./cart-item-delete";
export default function CartItem({ item, currencyCode }: {
item: HttpTypes.StoreCartLineItem
currencyCode: string
}) {
const { i18n: { language } } = useTranslation();
return (
<TableRow className="w-full" data-testid="product-row">
<TableCell className="text-left w-[100%] px-6">
<p
className="txt-medium-plus text-ui-fg-base"
data-testid="product-title"
>
{item.product_title}
</p>
</TableCell>
<TableCell className="px-6">
{item.quantity}
</TableCell>
<TableCell className="min-w-[80px] px-6">
{formatCurrency({
value: item.unit_price,
currencyCode,
locale: language,
})}
</TableCell>
<TableCell className="min-w-[80px] px-6">
{formatCurrency({
value: item.total,
currencyCode,
locale: language,
})}
</TableCell>
<TableCell className="text-right px-6">
<span className="flex gap-x-1 justify-end w-[60px]">
<CartItemDelete id={item.id} />
</span>
</TableCell>
</TableRow>
)
}

View File

@@ -0,0 +1,54 @@
import { StoreCart, StoreCartLineItem } from "@medusajs/types"
import { Trans } from '@kit/ui/trans';
import CartItem from "./cart-item";
import {
Table,
TableBody,
TableHead,
TableRow,
TableHeader,
} from '@kit/ui/table';
export default function CartItems({ cart, items, productColumnLabelKey }: {
cart: StoreCart;
items: StoreCartLineItem[];
productColumnLabelKey: string;
}) {
if (!items || items.length === 0) {
return null;
}
return (
<Table className="rounded-lg border border-separate">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="px-6">
<Trans i18nKey={productColumnLabelKey} />
</TableHead>
<TableHead className="px-6">
<Trans i18nKey="cart:table.quantity" />
</TableHead>
<TableHead className="px-6 min-w-[100px]">
<Trans i18nKey="cart:table.price" />
</TableHead>
<TableHead className="px-6 min-w-[100px]">
<Trans i18nKey="cart:table.total" />
</TableHead>
<TableHead className="px-6">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items
.sort((a, b) => (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1)
.map((item) => (
<CartItem
key={item.id}
item={item}
currencyCode={cart.currency_code}
/>
))}
</TableBody>
</Table>
)
}

View File

@@ -0,0 +1,91 @@
"use client";
import { Button } from '@kit/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from "@kit/ui/alert-dialog";
import { Timer } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StoreCartLineItem } from '@medusajs/types';
import { handleLineItemTimeout } from '@/lib/services/medusaCart.service';
const TIMEOUT_MINUTES = 15;
export default function CartTimer({ cartItem }: { cartItem: StoreCartLineItem }) {
const { t } = useTranslation();
const [timeLeft, setTimeLeft] = useState<number | null>(null);
const [isDialogOpen, setDialogOpen] = useState(false);
const updatedAt = cartItem.updated_at!;
useEffect(() => {
const date = new Date(updatedAt);
date.setMinutes(date.getMinutes() + TIMEOUT_MINUTES);
const interval = setInterval(() => {
const now = new Date();
const diff = date.getTime() - now.getTime();
setTimeLeft(diff);
}, 1000);
return () => clearInterval(interval);
}, [updatedAt]);
const minutes = timeLeft ? Math.floor((timeLeft % (1000 * 60 * 60)) / (1000 * 60)) : 0;
const seconds = timeLeft ? Math.floor((timeLeft % (1000 * 60)) / 1000) : 0;
const isTimeLeftPositive = timeLeft === null || timeLeft > 0;
useEffect(() => {
if (!isTimeLeftPositive) {
void handleLineItemTimeout({
lineItem: cartItem,
});
setDialogOpen(true);
}
}, [isTimeLeftPositive, cartItem.id]);
if (timeLeft === null) {
return <div className='min-h-[40px]' />;
}
return (
<>
<div className="ml-auto">
<Button variant="outline" className="flex items-center gap-x-2 bg-accent hover:bg-accent px-4 cursor-default">
<Timer />
<span className="text-sm">
{t('cart:checkout.timeLeft', {
timeLeft: `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`,
})}
</span>
</Button>
</div>
<AlertDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('cart:checkout.timeoutTitle')}
</AlertDialogTitle>
<AlertDialogDescription>
{t('cart:checkout.timeoutDescription', { productTitle: cartItem.product?.title })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setDialogOpen(false)}>
{t('cart:checkout.timeoutAction')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,166 @@
"use client"
import { Badge, Heading, Text } from "@medusajs/ui"
import React, { useActionState } from "react";
import { applyPromotions, submitPromotionForm } from "@lib/data/cart"
import { convertToLocale } from "@lib/util/money"
import { StoreCart, StorePromotion } from "@medusajs/types"
import Trash from "@modules/common/icons/trash"
import { Button } from '@kit/ui/button';
import { Form, FormControl, FormField, FormItem } from "@kit/ui/form";
import { Trans } from '@kit/ui/trans';
import { Input } from "@kit/ui/input";
import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
const DiscountCodeSchema = z.object({
code: z.string().min(1),
})
export default function DiscountCode({ cart }: {
cart: StoreCart & {
promotions: StorePromotion[]
}
}) {
const { t } = useTranslation('cart');
const { promotions = [] } = cart;
const removePromotionCode = async (code: string) => {
const validPromotions = promotions.filter(
(promotion) => promotion.code !== code
)
await applyPromotions(
validPromotions.filter((p) => p.code === undefined).map((p) => p.code!)
)
}
const addPromotionCode = async (code: string) => {
const codes = promotions
.filter((p) => p.code === undefined)
.map((p) => p.code!)
codes.push(code.toString())
await applyPromotions(codes)
form.reset()
}
const [message, formAction] = useActionState(submitPromotionForm, null)
const form = useForm<z.infer<typeof DiscountCodeSchema>>({
defaultValues: {
code: '',
},
resolver: zodResolver(DiscountCodeSchema),
});
return (
<div className="w-full bg-white flex flex-col txt-medium">
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => addPromotionCode(data.code))}
className="w-full mb-2 flex gap-x-2"
>
<FormField
name={'code'}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input required type="text" {...field} placeholder={t('cart:discountCode.placeholder')} />
</FormControl>
</FormItem>
)}
/>
<Button
type="submit"
variant="secondary"
className="h-full"
>
<Trans i18nKey={'cart:discountCode.apply'} />
</Button>
</form>
</Form>
<p className="text-sm text-muted-foreground">
<Trans i18nKey={'cart:discountCode.subtitle'} />
</p>
{promotions.length > 0 && (
<div className="w-full flex items-center">
<div className="flex flex-col w-full">
<Heading className="txt-medium mb-2">
Promotion(s) applied:
</Heading>
{promotions.map((promotion) => {
return (
<div
key={promotion.id}
className="flex items-center justify-between w-full max-w-full mb-2"
data-testid="discount-row"
>
<Text className="flex gap-x-1 items-baseline txt-small-plus w-4/5 pr-1">
<span className="truncate" data-testid="discount-code">
<Badge
color={promotion.is_automatic ? "green" : "grey"}
size="small"
>
{promotion.code}
</Badge>{" "}
(
{promotion.application_method?.value !== undefined &&
promotion.application_method.currency_code !==
undefined && (
<>
{promotion.application_method.type ===
"percentage"
? `${promotion.application_method.value}%`
: convertToLocale({
amount: promotion.application_method.value,
currency_code:
promotion.application_method
.currency_code,
})}
</>
)}
)
{/* {promotion.is_automatic && (
<Tooltip content="This promotion is automatically applied">
<InformationCircleSolid className="inline text-zinc-400" />
</Tooltip>
)} */}
</span>
</Text>
{!promotion.is_automatic && (
<button
className="flex items-center"
onClick={() => {
if (!promotion.code) {
return
}
removePromotionCode(promotion.code)
}}
data-testid="remove-discount-button"
>
<Trash size={14} />
<span className="sr-only">
Remove discount code from order
</span>
</button>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,111 @@
"use client";
import { StoreCart, StoreCartLineItem } from "@medusajs/types"
import CartItems from "./cart-items"
import { Trans } from '@kit/ui/trans';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardHeader,
} from '@kit/ui/card';
import DiscountCode from "./discount-code";
import { useRouter } from "next/navigation";
import { initiatePaymentSession } from "@lib/data/cart";
import { formatCurrency } from "@/packages/shared/src/utils";
import { useTranslation } from "react-i18next";
import { handleNavigateToPayment } from "@/lib/services/medusaCart.service";
const IS_DISCOUNT_SHOWN = false as boolean;
export default function Cart({
cart,
analysisPackages,
otherItems,
}: {
cart: StoreCart | null
analysisPackages: StoreCartLineItem[];
otherItems: StoreCartLineItem[];
}) {
const router = useRouter();
const { i18n: { language } } = useTranslation();
const items = cart?.items ?? [];
if (!cart || items.length === 0) {
return (
<div className="content-container py-5 lg:px-4">
<div>
<div className="flex flex-col justify-center items-center" data-testid="empty-cart-message">
<h4 className="text-center">
<Trans i18nKey="cart:emptyCartMessage" />
</h4>
<p className="text-center">
<Trans i18nKey="cart:emptyCartMessageDescription" />
</p>
</div>
</div>
</div>
);
}
async function handlePayment() {
const response = await initiatePaymentSession(cart!, {
provider_id: 'pp_system_default',
});
if (response.payment_collection) {
const url = await handleNavigateToPayment({ language });
router.push(url);
}
}
return (
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4">
<div className="flex flex-col bg-white gap-y-6">
<CartItems cart={cart} items={analysisPackages} productColumnLabelKey="cart:items.analysisPackages.productColumnLabel" />
<CartItems cart={cart} items={otherItems} productColumnLabelKey="cart:items.services.productColumnLabel" />
</div>
{Array.isArray(cart.items) && cart.items.length > 0 && (
<div className="flex justify-end gap-x-4 px-6 py-4">
<div className="mr-[36px]">
<p className="ml-0 font-bold text-sm">
<Trans i18nKey="cart:total" />
</p>
</div>
<div className="mr-[116px]">
<p className="text-sm">
{formatCurrency({
value: cart.total,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
)}
<div className="flex gap-y-6 py-8">
{IS_DISCOUNT_SHOWN && (
<Card
className="flex flex-col justify-between w-1/2"
>
<CardHeader className="pb-4">
<h5>
<Trans i18nKey="cart:discountCode.title" />
</h5>
</CardHeader>
<CardContent>
<DiscountCode cart={{ ...cart }} />
</CardContent>
</Card>
)}
</div>
<div>
<Button className="h-10" onClick={handlePayment}>
<Trans i18nKey="cart:checkout.goToCheckout" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
import { placeOrder } from "@lib/data/cart"
import Link from 'next/link';
import GlobalLoader from '../../loading';
enum Status {
LOADING = 'LOADING',
ERROR = 'ERROR',
}
export function MontonioCheckoutCallback() {
const router = useRouter();
const [status, setStatus] = useState<Status>(Status.LOADING);
const [isFinalized, setIsFinalized] = useState(false);
const searchParams = useSearchParams();
useEffect(() => {
if (isFinalized) {
return;
}
const token = searchParams.get('order-token');
if (!token) {
//router.push('/home/cart');
return;
}
async function verifyToken() {
setStatus(Status.LOADING);
try {
const response = await fetch('/api/montonio/verify-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token }),
});
setIsFinalized(true);
if (!response.ok) {
const body = await response.json();
throw new Error(body.error ?? 'Failed to verify payment status.');
}
const body = await response.json();
const paymentStatus = body.status as string;
if (paymentStatus === 'PAID') {
try {
await placeOrder();
} catch (e) {
console.error("Error placing order", e);
router.push('/home/cart');
}
} else {
throw new Error('Payment failed or pending');
}
} catch (e) {
console.error("Error verifying token", e);
setStatus(Status.ERROR);
}
}
void verifyToken();
}, [searchParams, isFinalized]);
if (status === Status.ERROR) {
return (
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'checkout.error.title'} />
</AlertTitle>
<AlertDescription>
<p>
<Trans i18nKey={'checkout.error.description'} />
</p>
</AlertDescription>
</Alert>
<div className={'flex'}>
<Button asChild>
<Link href={'/home'}>
<Trans i18nKey={'checkout.goToDashboard'} />
</Link>
</Button>
</div>
</div>
);
}
return <GlobalLoader />;
}

View File

@@ -18,12 +18,11 @@ import {
TableHeader,
TableRow,
} from '@kit/ui/table';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PackageHeader } from '../../../../components/package-header';
import { InfoTooltip } from '../../../../components/ui/info-tooltip';
import { PackageHeader } from '@/components/package-header';
import { InfoTooltip } from '@/components/ui/info-tooltip';
import { StoreProduct } from '@medusajs/types';
const dummyCards = [
{
@@ -107,8 +106,10 @@ const CheckWithBackground = () => {
};
const ComparePackagesModal = async ({
analysisPackages,
triggerElement,
}: {
analysisPackages: StoreProduct[];
triggerElement: JSX.Element;
}) => {
const { t, language } = await createI18nServerInstance();
@@ -142,21 +143,25 @@ const ComparePackagesModal = async ({
<TableHeader>
<TableRow>
<TableHead></TableHead>
{dummyCards.map(
({ titleKey, price, nrOfAnalyses, tagColor }) => (
<TableHead key={titleKey} className="py-2">
<PackageHeader
title={t(titleKey)}
tagColor={tagColor}
analysesNr={t('product:nrOfAnalyses', {
nr: nrOfAnalyses,
})}
language={language}
price={price}
/>
</TableHead>
),
)}
{analysisPackages.map(
(product) => {
const variant = product.variants?.[0];
const titleKey = product.title;
const price = variant?.calculated_price?.calculated_amount ?? 0;
return (
<TableHead key={titleKey} className="py-2">
<PackageHeader
title={t(titleKey)}
tagColor='bg-cyan'
analysesNr={t('product:nrOfAnalyses', {
nr: product?.metadata?.nrOfAnalyses ?? 0,
})}
language={language}
price={price}
/>
</TableHead>
)
})}
</TableRow>
</TableHeader>
<TableBody>

View File

@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import { InfoTooltip } from '@/components/ui/info-tooltip';
import { toTitleCase } from '@/lib/utils';
import { BlendingModeIcon, RulerHorizontalIcon } from '@radix-ui/react-icons';
import {
Activity,
@@ -15,8 +15,6 @@ import {
User,
} from 'lucide-react';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
import { Button } from '@kit/ui/button';
import {
Card,
@@ -26,7 +24,6 @@ import {
CardHeader,
CardProps,
} from '@kit/ui/card';
import { PageDescription } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
@@ -107,6 +104,7 @@ const dummyRecommendations = [
tooltipContent: 'Selgitus',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
{
icon: <BlendingModeIcon className="size-4" />,
@@ -115,6 +113,7 @@ const dummyRecommendations = [
tooltipContent: 'Selgitus',
description: 'LDL-Kolesterool',
buttonText: 'Broneeri',
href: '/home/booking',
},
{
icon: <Droplets />,
@@ -124,24 +123,13 @@ const dummyRecommendations = [
description: 'Score-Risk 2',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
];
export default function Dashboard() {
const userWorkspace = useUserWorkspace();
const account = usePersonalAccountData(userWorkspace.user.id);
return (
<>
<div>
<h4>
<Trans i18nKey={'common:welcome'} />
{account?.data?.name ? `, ${toTitleCase(account.data.name)}` : ''}
</h4>
<PageDescription>
<Trans i18nKey={'dashboard:recentlyCheckedDescription'} />:
</PageDescription>
</div>
<div className="grid auto-rows-fr grid-cols-5 gap-3">
{dummyCards.map(
({
@@ -196,6 +184,7 @@ export default function Dashboard() {
tooltipContent,
price,
buttonText,
href,
},
index,
) => {
@@ -222,9 +211,17 @@ export default function Dashboard() {
</div>
<div className="grid w-36 auto-rows-fr grid-cols-2 items-center gap-4">
<p className="text-sm font-medium"> {price}</p>
<Button size="sm" variant="secondary">
{buttonText}
</Button>
{href ? (
<Link href={href}>
<Button size="sm" variant="secondary">
{buttonText}
</Button>
</Link>
) : (
<Button size="sm" variant="secondary">
{buttonText}
</Button>
)}
</div>
</div>
);

View File

@@ -1,19 +1,30 @@
import Link from 'next/link';
import { ShoppingCart } from 'lucide-react';
import { Trans } from '@kit/ui/trans';
import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
import { Search } from '~/components/ui/search';
import { SIDEBAR_WIDTH_PROPERTY } from '@/packages/ui/src/shadcn/constants';
import { Button } from '@kit/ui/button';
import { SIDEBAR_WIDTH_PROPERTY } from '../../../../packages/ui/src/shadcn/constants';
// home imports
import { UserNotifications } from '../_components/user-notifications';
import { type UserWorkspace } from '../_lib/server/load-user-workspace';
import { Button } from '@kit/ui/button';
import { ShoppingCart } from 'lucide-react';
import { StoreCart } from '@medusajs/types';
import { formatCurrency } from '@/packages/shared/src/utils';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
export async function HomeMenuNavigation(props: { workspace: UserWorkspace, cart: StoreCart | null }) {
const { language } = await createI18nServerInstance();
const { workspace, user, accounts } = props.workspace;
const totalValue = props.cart?.total ? formatCurrency({
currencyCode: props.cart.currency_code,
locale: language,
value: props.cart.total,
}) : 0;
const cartItemsCount = props.cart?.items?.length ?? 0;
const hasCartItems = cartItemsCount > 0;
return (
<div className={'flex w-full flex-1 items-center justify-between gap-3'}>
@@ -27,13 +38,17 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
/>
<div className="flex items-center justify-end gap-3">
<Button className='relative px-4 py-2 h-10 border-1 mr-0 cursor-pointer' variant='ghost'>
<span className='flex items-center text-nowrap'> 231,89</span>
</Button>
<Button variant="ghost" className='relative px-4 py-2 h-10 border-1 mr-0 cursor-pointer' >
<ShoppingCart className="stroke-[1.5px]" />
<Trans i18nKey="common:shoppingCart" /> (0)
</Button>
{hasCartItems && (
<Button className='relative px-4 py-2 h-10 border-1 mr-0 cursor-pointer' variant='ghost'>
<span className='flex items-center text-nowrap'>{totalValue}</span>
</Button>
)}
<Link href='/home/cart'>
<Button variant="ghost" className='relative px-4 py-2 h-10 border-1 mr-0 cursor-pointer' >
<ShoppingCart className="stroke-[1.5px]" />
<Trans i18nKey="common:shoppingCart" /> ({hasCartItems ? cartItemsCount : 0})
</Button>
</Link>
<UserNotifications userId={user.id} />
<div>

View File

@@ -7,6 +7,6 @@ export function HomeLayoutPageHeader(
}>,
) {
return (
<PageHeader description={props.description}>{props.children}</PageHeader>
<PageHeader description={props.description} title={props.title}>{props.children}</PageHeader>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { ChevronRight, HeartPulse } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@kit/ui/button';
import {
Card,
CardHeader,
CardDescription,
CardProps,
CardFooter,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { cn } from '@/lib/utils';
const dummyCards = [
{
title: 'booking:analysisPackages.title',
description: 'booking:analysisPackages.description',
descriptionColor: 'text-primary',
icon: (
<Link href={'/home/order-analysis-package'}>
<Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" />
</Button>
</Link>
),
cardVariant: 'gradient-success' as CardProps['variant'],
iconBg: 'bg-warning',
},
];
export default function OrderCards() {
return (
<div className="grid grid-cols-3 gap-6 mt-4">
{dummyCards.map(({
title,
description,
icon,
cardVariant,
descriptionColor,
iconBg,
}) => (
<Card
key={title}
variant={cardVariant}
className="flex flex-col justify-between"
>
<CardHeader className="items-end-safe">
<div
className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
iconBg,
)}
>
{icon}
</div>
</CardHeader>
<CardFooter className="flex flex-col items-start gap-2">
<div
className={'flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-primary\/10 mb-6'}
>
<HeartPulse className="size-4 fill-green-500" />
</div>
<h5>
<Trans i18nKey={title} />
</h5>
<CardDescription className={descriptionColor}>
<Trans i18nKey={description} />
</CardDescription>
</CardFooter>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client"
import { formatCurrency } from "@/packages/shared/src/utils"
import { StoreOrder } from "@medusajs/types"
import React from "react"
import { useTranslation } from "react-i18next"
import { Trans } from '@kit/ui/trans';
export default function CartTotals({ order }: {
order: StoreOrder
}) {
const { i18n: { language } } = useTranslation()
const {
currency_code,
total,
subtotal,
tax_total,
discount_total,
gift_card_total,
} = order
return (
<div>
<div className="flex flex-col gap-y-2 txt-medium text-ui-fg-subtle ">
<div className="flex items-center justify-between">
<span className="flex gap-x-1 items-center">
<Trans i18nKey="cart:orderConfirmed.subtotal" />
</span>
<span data-testid="cart-subtotal" data-value={subtotal || 0}>
{formatCurrency({ value: subtotal ?? 0, currencyCode: currency_code, locale: language })}
</span>
</div>
{!!discount_total && (
<div className="flex items-center justify-between">
<span><Trans i18nKey="cart:orderConfirmed.discount" /></span>
<span
className="text-ui-fg-interactive"
data-testid="cart-discount"
data-value={discount_total || 0}
>
-{" "}
{formatCurrency({ value: discount_total ?? 0, currencyCode: currency_code, locale: language })}
</span>
</div>
)}
<div className="flex justify-between">
<span className="flex gap-x-1 items-center ">
<Trans i18nKey="cart:orderConfirmed.taxes" />
</span>
<span data-testid="cart-taxes" data-value={tax_total || 0}>
{formatCurrency({ value: tax_total ?? 0, currencyCode: currency_code, locale: language })}
</span>
</div>
{!!gift_card_total && (
<div className="flex items-center justify-between">
<span><Trans i18nKey="cart:orderConfirmed.giftCard" /></span>
<span
className="text-ui-fg-interactive"
data-testid="cart-gift-card-amount"
data-value={gift_card_total || 0}
>
-{" "}
{formatCurrency({ value: gift_card_total ?? 0, currencyCode: currency_code, locale: language })}
</span>
</div>
)}
</div>
<div className="h-px w-full border-b border-gray-200 my-4" />
<div className="flex items-center justify-between text-ui-fg-base mb-2 txt-medium ">
<span className="font-bold"><Trans i18nKey="cart:orderConfirmed.total" /></span>
<span
className="txt-xlarge-plus"
data-testid="cart-total"
data-value={total || 0}
>
{formatCurrency({ value: total ?? 0, currencyCode: currency_code, locale: language })}
</span>
</div>
<div className="h-px w-full border-b border-gray-200 mt-4" />
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { Trans } from '@kit/ui/trans';
import { PageBody, PageHeader } from '@kit/ui/page';
import { StoreOrder } from "@medusajs/types"
import Divider from "@modules/common/components/divider"
import CartTotals from "./cart-totals"
import OrderDetails from "./order-details"
import OrderItems from "./order-items"
export default async function OrderCompleted({
order,
}: {
order: StoreOrder,
}) {
return (
<PageBody>
<PageHeader title={<Trans i18nKey="cart:orderConfirmed.title" />} />
<Divider />
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4 gap-y-6">
<OrderDetails order={order} />
<Divider />
<OrderItems order={order} />
<CartTotals order={order} />
</div>
</PageBody>
)
}

View File

@@ -0,0 +1,47 @@
import { StoreOrder } from "@medusajs/types"
import { Trans } from '@kit/ui/trans';
export default function OrderDetails({ order, showStatus }: {
order: StoreOrder
showStatus?: boolean
}) {
const formatStatus = (str: string) => {
const formatted = str.split("_").join(" ")
return formatted.slice(0, 1).toUpperCase() + formatted.slice(1)
}
return (
<div className="flex flex-col gap-y-2">
<span>
<Trans i18nKey="cart:orderConfirmed.orderDate" />:{" "}
<span>
{new Date(order.created_at).toLocaleDateString()}
</span>
</span>
<span className="text-ui-fg-interactive">
<Trans i18nKey="cart:orderConfirmed.orderNumber" />: <span data-testid="order-id">{order.display_id}</span>
</span>
{showStatus && (
<>
<span>
<Trans i18nKey="cart:orderConfirmed.orderStatus" />:{" "}
<span className="text-ui-fg-subtle">
{formatStatus(order.fulfillment_status)}
</span>
</span>
<span>
<Trans i18nKey="cart:orderConfirmed.paymentStatus" />:{" "}
<span
className="text-ui-fg-subtle "
data-testid="order-payment-status"
>
{formatStatus(order.payment_status)}
</span>
</span>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { StoreCartLineItem, StoreOrderLineItem } from "@medusajs/types"
import { TableCell, TableRow } from "@kit/ui/table"
import LineItemOptions from "@modules/common/components/line-item-options"
import LineItemPrice from "@modules/common/components/line-item-price"
import LineItemUnitPrice from "@modules/common/components/line-item-unit-price"
export default function OrderItem({ item, currencyCode }: {
item: StoreCartLineItem | StoreOrderLineItem
currencyCode: string
}) {
return (
<TableRow className="w-full" data-testid="product-row">
{/* <TableCell className="px-6 w-24">
<div className="flex w-16">
<Thumbnail thumbnail={item.thumbnail} size="square" />
</div>
</TableCell> */}
<TableCell className="text-left px-6">
<span
className="txt-medium-plus text-ui-fg-base"
data-testid="product-name"
>
{item.product_title}
</span>
<LineItemOptions variant={item.variant} data-testid="product-variant" />
</TableCell>
<TableCell className="px-6">
<span className="flex flex-col items-end h-full justify-center">
<span className="flex gap-x-1 ">
<span className="text-ui-fg-muted">
{item.quantity}x{" "}
</span>
<LineItemUnitPrice
item={item}
style="tight"
currencyCode={currencyCode}
/>
</span>
<LineItemPrice
item={item}
style="tight"
currencyCode={currencyCode}
/>
</span>
</TableCell>
</TableRow>
)
}

View File

@@ -0,0 +1,39 @@
import repeat from "@lib/util/repeat"
import { StoreOrder } from "@medusajs/types"
import { Table, TableBody } from "@kit/ui/table"
import SkeletonLineItem from "@modules/skeletons/components/skeleton-line-item"
import OrderItem from "./order-item"
import { Heading } from "@kit/ui/heading"
import { Trans } from '@kit/ui/trans';
export default function OrderItems({ order }: {
order: StoreOrder
}) {
const items = order.items
return (
<div className="flex flex-col gap-y-4">
<Heading level={5} className="flex flex-row text-3xl-regular">
<Trans i18nKey="cart:orderConfirmed.summary" />
</Heading>
<div className="flex flex-col">
<Table className="rounded-lg border border-separate">
<TableBody data-testid="products-table">
{items?.length
? items
.sort((a, b) => (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1)
.map((item) => (
<OrderItem
key={item.id}
item={item}
currencyCode={order.currency_code}
/>
))
: repeat(5).map((i) => <SkeletonLineItem key={i} />)}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { cache } from 'react';
import { listCollections, listProducts, listRegions } from "@lib/data";
async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) =>
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat(),
);
return countryCodes ?? [];
}
export const loadCountryCodes = cache(countryCodesLoader);
async function collectionsLoader() {
const { collections } = await listCollections({
fields: 'id, handle',
});
return collections ?? [];
}
export const loadCollections = cache(collectionsLoader);
async function analysisPackagesLoader() {
const [countryCodes, collections] = await Promise.all([loadCountryCodes(), loadCollections()]);
const countryCode = countryCodes[0]!;
const collection = collections.find(({ handle }) => handle === 'analysis-packages');
if (!collection) {
return { analysisPackages: [], countryCode };
}
const { response } = await listProducts({
countryCode,
queryParams: { limit: 100, collection_id: collection?.id },
});
return { analysisPackages: response.products, countryCode };
}
export const loadAnalysisPackages = cache(analysisPackagesLoader);

View File

@@ -2,6 +2,7 @@ import { cache } from 'react';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { requireUserInServerComponent } from '@/lib/server/require-user-in-server-component';
export type UserAccount = Awaited<ReturnType<typeof loadUserAccount>>;
@@ -13,6 +14,13 @@ export type UserAccount = Awaited<ReturnType<typeof loadUserAccount>>;
*/
export const loadUserAccount = cache(accountLoader);
export async function loadCurrentUserAccount() {
const user = await requireUserInServerComponent();
return user?.identities?.[0]?.id
? await loadUserAccount(user?.identities?.[0]?.id)
: null;
}
async function accountLoader(accountId: string) {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);

View File

@@ -35,13 +35,13 @@ async function workspaceLoader() {
accountsPromise(),
workspacePromise,
requireUserInServerComponent(),
tempAccountsPromise()
tempAccountsPromise(),
]);
return {
accounts,
workspace,
user,
tempVisibleAccounts
tempVisibleAccounts,
};
}

View File

@@ -46,9 +46,11 @@ async function loadAccountMembers(
client: SupabaseClient<Database>,
account: string,
) {
const { data, error } = await client.rpc('get_account_members', {
account_slug: account,
});
const { data, error } = await client
.schema('medreport')
.rpc('get_account_members', {
account_slug: account,
});
if (error) {
console.error(error);
@@ -67,9 +69,11 @@ async function loadInvitations(
client: SupabaseClient<Database>,
account: string,
) {
const { data, error } = await client.rpc('get_account_invitations', {
account_slug: account,
});
const { data, error } = await client
.schema('medreport')
.rpc('get_account_invitations', {
account_slug: account,
});
if (error) {
console.error(error);

View File

@@ -1,6 +1,6 @@
import { requireUserInServerComponent } from '../../lib/server/require-user-in-server-component';
import { requireUserInServerComponent } from '@/lib/server/require-user-in-server-component';
import ConsentDialog from './(user)/_components/consent-dialog';
import { loadUserAccount } from './(user)/_lib/server/load-user-account';
import { loadCurrentUserAccount } from './(user)/_lib/server/load-user-account';
export default async function HomeLayout({
children,
@@ -8,9 +8,7 @@ export default async function HomeLayout({
children: React.ReactNode;
}) {
const user = await requireUserInServerComponent();
const account = user?.identities?.[0]?.id
? await loadUserAccount(user?.identities?.[0]?.id)
: null;
const account = await loadCurrentUserAccount()
if (account && account?.has_consent_anonymized_company_statistics === null) {
return (

View File

@@ -79,12 +79,11 @@ async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) {
// we need to verify the user isn't already in the account
// we do so by checking if the user can read the account
// if the user can read the account, then they are already in the account
const { data: isAlreadyTeamMember } = await client.rpc(
'is_account_team_member',
{
const { data: isAlreadyTeamMember } = await client
.schema('medreport')
.rpc('is_account_team_member', {
target_account_id: invitation.account.id,
},
);
});
// if the user is already in the account redirect to the home page
if (isAlreadyTeamMember) {

View File

@@ -1,26 +1,19 @@
import Image from 'next/image';
import Link from 'next/link';
import { CaretRightIcon } from '@radix-ui/react-icons';
import { Scale } from 'lucide-react';
import { Trans } from '@kit/ui/trans';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from '@kit/ui/card';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import SelectAnalysisPackages from '@/components/select-analysis-packages';
import { MedReportLogo } from '../../components/med-report-logo';
import { PackageHeader } from '../../components/package-header';
import { ButtonTooltip } from '../../components/ui/button-tooltip';
import pathsConfig from '../../config/paths.config';
import ComparePackagesModal from '../home/(user)/_components/compare-packages-modal';
import { loadAnalysisPackages } from '../home/(user)/_lib/server/load-analysis-packages';
export const generateMetadata = async () => {
const { t } = await createI18nServerInstance();
@@ -30,100 +23,33 @@ export const generateMetadata = async () => {
};
};
const dummyCards = [
{
titleKey: 'product:standard.label',
price: 40,
nrOfAnalyses: 4,
tagColor: 'bg-cyan',
descriptionKey: 'marketing:standard.description',
},
{
titleKey: 'product:standardPlus.label',
price: 85,
nrOfAnalyses: 10,
tagColor: 'bg-warning',
descriptionKey: 'product:standardPlus.description',
},
{
titleKey: 'product:premium.label',
price: 140,
nrOfAnalyses: '12+',
tagColor: 'bg-purple',
descriptionKey: 'product:premium.description',
},
];
async function SelectPackagePage() {
const { t, language } = await createI18nServerInstance();
const { analysisPackages, countryCode } = await loadAnalysisPackages();
return (
<div className="container mx-auto my-24 flex flex-col items-center space-y-12">
<MedReportLogo />
<div className="space-y-3 text-center">
<h3>{t('marketing:selectPackage')}</h3>
<h3>
<Trans i18nKey={'marketing:selectPackage'} />
</h3>
<ComparePackagesModal
analysisPackages={analysisPackages}
triggerElement={
<Button variant="secondary" className="gap-2">
{t('marketing:comparePackages')}
<Trans i18nKey={'marketing:comparePackages'} />
<Scale className="size-4 stroke-[1.5px]" />
</Button>
}
/>
</div>
<div className="grid grid-cols-3 gap-6">
{dummyCards.map(
(
{ titleKey, price, nrOfAnalyses, tagColor, descriptionKey },
index,
) => {
return (
<Card key={index}>
<CardHeader className="relative">
<ButtonTooltip
content="Content pending"
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={tagColor}
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
language={language}
price={price}
/>
<CardDescription>{t(descriptionKey)}</CardDescription>
</CardContent>
<CardFooter>
<Button className="w-full">
{t('marketing:selectThisPackage')}
</Button>
</CardFooter>
</Card>
);
},
)}
<div className="col-span-3 grid grid-cols-subgrid">
<div className="col-start-2 justify-self-center-safe">
<Link href={pathsConfig.app.home}>
<Button variant="secondary" className="align-center">
{t('marketing:notInterestedInAudit')}{' '}
<CaretRightIcon className="size-4" />
</Button>
</Link>
</div>
</div>
</div>
<SelectAnalysisPackages analysisPackages={analysisPackages} countryCode={countryCode} />
<Link href={pathsConfig.app.home}>
<Button variant="secondary" className="align-center">
<Trans i18nKey={'marketing:notInterestedInAudit'} />{' '}
<CaretRightIcon className="size-4" />
</Button>
</Link>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { retrieveCart } from "@lib/data/cart"
import { retrieveCustomer } from "@lib/data/customer"
import PaymentWrapper from "@modules/checkout/components/payment-wrapper"
import CheckoutForm from "@modules/checkout/templates/checkout-form"
import CheckoutSummary from "@modules/checkout/templates/checkout-summary"
import { Metadata } from "next"
import { notFound } from "next/navigation"
export const metadata: Metadata = {
title: "Checkout",
}
export default async function Checkout() {
const cart = await retrieveCart()
if (!cart) {
return notFound()
}
const customer = await retrieveCustomer()
return (
<div className="grid grid-cols-1 small:grid-cols-[1fr_416px] content-container gap-x-40 py-12">
<PaymentWrapper cart={cart}>
<CheckoutForm cart={cart} customer={customer} />
</PaymentWrapper>
<CheckoutSummary cart={cart} />
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { LocalizedClientLink } from '~/medusa/modules/common/components';
import { ChevronDownIcon } from '~/medusa/modules/common/icons';
import { MedusaCTA } from '~/medusa/modules/layout/components';
export default function CheckoutLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="small:min-h-screen relative w-full bg-white">
<div className="h-16 border-b bg-white">
<nav className="content-container flex h-full items-center justify-between">
<LocalizedClientLink
href="/cart"
className="text-small-semi text-ui-fg-base flex flex-1 basis-0 items-center gap-x-2 uppercase"
data-testid="back-to-cart-link"
>
<ChevronDownIcon className="rotate-90" size={16} />
<span className="small:block txt-compact-plus text-ui-fg-subtle hover:text-ui-fg-base mt-px hidden">
Back to shopping cart
</span>
<span className="small:hidden txt-compact-plus text-ui-fg-subtle hover:text-ui-fg-base mt-px block">
Back
</span>
</LocalizedClientLink>
<LocalizedClientLink
href="/"
className="txt-compact-xlarge-plus text-ui-fg-subtle hover:text-ui-fg-base uppercase"
data-testid="store-link"
>
Medusa Store
</LocalizedClientLink>
<div className="flex-1 basis-0" />
</nav>
</div>
<div className="relative" data-testid="checkout-container">
{children}
</div>
<div className="flex w-full items-center justify-center py-4">
<MedusaCTA />
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Metadata } from 'next';
import InteractiveLink from '~/medusa/modules/common/components/interactive-link';
export const metadata: Metadata = {
title: '404',
description: 'Something went wrong',
};
export default async function NotFound() {
return (
<div className="flex min-h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The page you tried to access does not exist.
</p>
<InteractiveLink href="/">Go to frontpage</InteractiveLink>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { retrieveCustomer } from '~/medusa/lib/data/customer';
import { getRegion } from '~/medusa/lib/data/regions';
import AddressBook from '~/medusa/modules/account/components/address-book';
export const metadata: Metadata = {
title: 'Addresses',
description: 'View your addresses',
};
export default async function Addresses(props: {
params: Promise<{ countryCode: string }>;
}) {
const params = await props.params;
const { countryCode } = params;
const customer = await retrieveCustomer();
const region = await getRegion(countryCode);
if (!customer || !region) {
notFound();
}
return (
<div className="w-full" data-testid="addresses-page-wrapper">
<div className="mb-8 flex flex-col gap-y-4">
<h1 className="text-2xl-semi">Shipping Addresses</h1>
<p className="text-base-regular">
View and update your shipping addresses, you can add as many as you
like. Saving your addresses will make them available during checkout.
</p>
</div>
<AddressBook customer={customer} region={region} />
</div>
);
}

View File

@@ -0,0 +1,9 @@
import Spinner from '~/medusa/modules/common/icons/spinner';
export default function Loading() {
return (
<div className="text-ui-fg-base flex h-full w-full items-center justify-center">
<Spinner size={36} />
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { retrieveOrder } from '~/medusa/lib/data/orders';
import OrderDetailsTemplate from '~/medusa/modules/order/templates/order-details-template';
type Props = {
params: Promise<{ id: string }>;
};
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params;
const order = await retrieveOrder(params.id).catch(() => null);
if (!order) {
notFound();
}
return {
title: `Order #${order.display_id}`,
description: `View your order`,
};
}
export default async function OrderDetailPage(props: Props) {
const params = await props.params;
const order = await retrieveOrder(params.id).catch(() => null);
if (!order) {
notFound();
}
return <OrderDetailsTemplate order={order} />;
}

View File

@@ -0,0 +1,38 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { listOrders } from '~/medusa/lib/data/orders';
import OrderOverview from '~/medusa/modules/account/components/order-overview';
import TransferRequestForm from '~/medusa/modules/account/components/transfer-request-form';
import Divider from '~/medusa/modules/common/components/divider';
export const metadata: Metadata = {
title: 'Orders',
description: 'Overview of your previous orders.',
};
export default async function Orders() {
const orders = await listOrders();
if (!orders) {
notFound();
}
return (
<div className="w-full" data-testid="orders-page-wrapper">
<div className="mb-8 flex flex-col gap-y-4">
<h1 className="text-2xl-semi">Orders</h1>
<p className="text-base-regular">
View your previous orders and their status. You can also create
returns or exchanges for your orders if needed.
</p>
</div>
<div>
<OrderOverview orders={orders} />
<Divider className="my-16" />
<TransferRequestForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { retrieveCustomer } from '~/medusa/lib/data/customer';
import { listOrders } from '~/medusa/lib/data/orders';
import Overview from '~/medusa/modules/account/components/overview';
export const metadata: Metadata = {
title: 'Account',
description: 'Overview of your account activity.',
};
export default async function OverviewTemplate() {
const customer = await retrieveCustomer().catch(() => null);
const orders = (await listOrders().catch(() => null)) || null;
if (!customer) {
notFound();
}
return <Overview customer={customer} orders={orders} />;
}

View File

@@ -0,0 +1,54 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { retrieveCustomer } from '~/medusa/lib/data/customer';
import { listRegions } from '~/medusa/lib/data/regions';
import ProfilePhone from '~/medusa/modules/account//components/profile-phone';
import ProfileBillingAddress from '~/medusa/modules/account/components/profile-billing-address';
import ProfileEmail from '~/medusa/modules/account/components/profile-email';
import ProfileName from '~/medusa/modules/account/components/profile-name';
import ProfilePassword from '~/medusa/modules/account/components/profile-password';
export const metadata: Metadata = {
title: 'Profile',
description: 'View and edit your Medusa Store profile.',
};
export default async function Profile() {
const customer = await retrieveCustomer();
const regions = await listRegions();
if (!customer || !regions) {
notFound();
}
return (
<div className="w-full" data-testid="profile-page-wrapper">
<div className="mb-8 flex flex-col gap-y-4">
<h1 className="text-2xl-semi">Profile</h1>
<p className="text-base-regular">
View and update your profile information, including your name, email,
and phone number. You can also update your billing address, or change
your password.
</p>
</div>
<div className="flex w-full flex-col gap-y-8">
<ProfileName customer={customer} />
<Divider />
<ProfileEmail customer={customer} />
<Divider />
<ProfilePhone customer={customer} />
<Divider />
{/* <ProfilePassword customer={customer} />
<Divider /> */}
<ProfileBillingAddress customer={customer} regions={regions} />
</div>
</div>
);
}
const Divider = () => {
return <div className="h-px w-full bg-gray-200" />;
};
``;

View File

@@ -0,0 +1,12 @@
import { Metadata } from 'next';
import LoginTemplate from '~/medusa/modules/account/templates/login-template';
export const metadata: Metadata = {
title: 'Sign in',
description: 'Sign in to your Medusa Store account.',
};
export default function Login() {
return <LoginTemplate />;
}

View File

@@ -0,0 +1,21 @@
import { Toaster } from '@medusajs/ui';
import { retrieveCustomer } from '~/medusa/lib/data';
import { AccountLayout } from '~/medusa/modules/account/templates';
export default async function AccountPageLayout({
dashboard,
login,
}: {
dashboard?: React.ReactNode;
login?: React.ReactNode;
}) {
const customer = await retrieveCustomer().catch(() => null);
return (
<AccountLayout customer={customer}>
{customer ? dashboard : login}
<Toaster />
</AccountLayout>
);
}

View File

@@ -0,0 +1,9 @@
import Spinner from '~/medusa/modules/common/icons/spinner';
export default function Loading() {
return (
<div className="text-ui-fg-base flex h-full w-full items-center justify-center">
<Spinner size={36} />
</div>
);
}

View File

@@ -0,0 +1,5 @@
import SkeletonCartPage from '~/medusa/modules/skeletons/templates/skeleton-cart-page';
export default function Loading() {
return <SkeletonCartPage />;
}

View File

@@ -0,0 +1,21 @@
import { Metadata } from 'next';
import InteractiveLink from '~/medusa/modules/common/components/interactive-link';
export const metadata: Metadata = {
title: '404',
description: 'Something went wrong',
};
export default function NotFound() {
return (
<div className="flex min-h-[calc(100vh-64px)] flex-col items-center justify-center">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The cart you tried to access does not exist. Clear your cookies and try
again.
</p>
<InteractiveLink href="/">Go to frontpage</InteractiveLink>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { retrieveCart } from '~/medusa/lib/data/cart';
import { retrieveCustomer } from '~/medusa/lib/data/customer';
import CartTemplate from '~/medusa/modules/cart/templates';
export const metadata: Metadata = {
title: 'Cart',
description: 'View your cart',
};
export default async function Cart() {
const cart = await retrieveCart().catch((error) => {
console.error(error);
return notFound();
});
const customer = await retrieveCustomer();
return <CartTemplate cart={cart} customer={customer} />;
}

View File

@@ -0,0 +1,90 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { StoreRegion } from '@medusajs/types';
import {
getCategoryByHandle,
listCategories,
} from '~/medusa/lib/data/categories';
import { listRegions } from '~/medusa/lib/data/regions';
import CategoryTemplate from '~/medusa/modules/categories/templates';
import { SortOptions } from '~/medusa/modules/store/components/refinement-list/sort-products';
type Props = {
params: Promise<{ category: string[]; countryCode: string }>;
searchParams: Promise<{
sortBy?: SortOptions;
page?: string;
}>;
};
export async function generateStaticParams() {
const product_categories = await listCategories();
if (!product_categories) {
return [];
}
const countryCodes = await listRegions().then((regions: StoreRegion[]) =>
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat(),
);
const categoryHandles = product_categories.map(
(category: any) => category.handle,
);
const staticParams = countryCodes
?.map((countryCode: string | undefined) =>
categoryHandles.map((handle: any) => ({
countryCode,
category: [handle],
})),
)
.flat();
return staticParams;
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params;
try {
const productCategory = await getCategoryByHandle(params.category);
const title = productCategory.name + ' | Medusa Store';
const description = productCategory.description ?? `${title} category.`;
return {
title: `${title} | Medusa Store`,
description,
alternates: {
canonical: `${params.category.join('/')}`,
},
};
} catch (error) {
notFound();
}
}
export default async function CategoryPage(props: Props) {
const searchParams = await props.searchParams;
const params = await props.params;
const { sortBy, page } = searchParams;
const productCategory = await getCategoryByHandle(params.category);
if (!productCategory) {
notFound();
}
return (
<CategoryTemplate
category={productCategory}
sortBy={sortBy}
page={page}
countryCode={params.countryCode}
/>
);
}

View File

@@ -0,0 +1,95 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { StoreCollection, StoreRegion } from '@medusajs/types';
import {
getCollectionByHandle,
listCollections,
} from '~/medusa/lib/data/collections';
import { listRegions } from '~/medusa/lib/data/regions';
import CollectionTemplate from '~/medusa/modules/collections/templates';
import { SortOptions } from '~/medusa/modules/store/components/refinement-list/sort-products';
type Props = {
params: Promise<{ handle: string; countryCode: string }>;
searchParams: Promise<{
page?: string;
sortBy?: SortOptions;
}>;
};
export const PRODUCT_LIMIT = 12;
export async function generateStaticParams() {
const { collections } = await listCollections({
fields: '*products',
});
if (!collections) {
return [];
}
const countryCodes = await listRegions().then(
(regions: StoreRegion[]) =>
regions
?.map((r) => r.countries?.map((c) => c.iso_2))
.flat()
.filter(Boolean) as string[],
);
const collectionHandles = collections.map(
(collection: StoreCollection) => collection.handle,
);
const staticParams = countryCodes
?.map((countryCode: string) =>
collectionHandles.map((handle: string | undefined) => ({
countryCode,
handle,
})),
)
.flat();
return staticParams;
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params;
const collection = await getCollectionByHandle(params.handle);
if (!collection) {
notFound();
}
const metadata = {
title: `${collection.title} | Medusa Store`,
description: `${collection.title} collection`,
} as Metadata;
return metadata;
}
export default async function CollectionPage(props: Props) {
const searchParams = await props.searchParams;
const params = await props.params;
const { sortBy, page } = searchParams;
const collection = await getCollectionByHandle(params.handle).then(
(collection: StoreCollection) => collection,
);
if (!collection) {
notFound();
}
return (
<CollectionTemplate
collection={collection}
page={page}
sortBy={sortBy}
countryCode={params.countryCode}
/>
);
}

View File

@@ -0,0 +1,46 @@
import { Metadata } from 'next';
import { StoreCartShippingOption } from '@medusajs/types';
import { listCartOptions, retrieveCart } from '~/medusa/lib/data/cart';
import { retrieveCustomer } from '~/medusa/lib/data/customer';
import { getBaseURL } from '~/medusa/lib/util/env';
import CartMismatchBanner from '~/medusa/modules/layout/components/cart-mismatch-banner';
import Footer from '~/medusa/modules/layout/templates/footer';
import Nav from '~/medusa/modules/layout/templates/nav';
import FreeShippingPriceNudge from '~/medusa/modules/shipping/components/free-shipping-price-nudge';
export const metadata: Metadata = {
metadataBase: new URL(getBaseURL()),
};
export default async function PageLayout(props: { children: React.ReactNode }) {
const customer = await retrieveCustomer();
const cart = await retrieveCart();
let shippingOptions: StoreCartShippingOption[] = [];
if (cart) {
const { shipping_options } = await listCartOptions();
shippingOptions = shipping_options;
}
return (
<>
<Nav />
{customer && cart && (
<CartMismatchBanner customer={customer} cart={cart} />
)}
{cart && (
<FreeShippingPriceNudge
variant="popup"
cart={cart}
shippingOptions={shippingOptions}
/>
)}
{props.children}
<Footer />
</>
);
}

View File

@@ -0,0 +1,20 @@
import { Metadata } from 'next';
import InteractiveLink from '~/medusa/modules/common/components/interactive-link';
export const metadata: Metadata = {
title: '404',
description: 'Something went wrong',
};
export default function NotFound() {
return (
<div className="flex min-h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The page you tried to access does not exist.
</p>
<InteractiveLink href="/">Go to frontpage</InteractiveLink>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import SkeletonOrderConfirmed from '~/medusa/modules/skeletons/templates/skeleton-order-confirmed';
export default function Loading() {
return <SkeletonOrderConfirmed />;
}

View File

@@ -0,0 +1,25 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { retrieveOrder } from '~/medusa/lib/data/orders';
import OrderCompletedTemplate from '~/medusa/modules/order/templates/order-completed-template';
type Props = {
params: Promise<{ id: string }>;
};
export const metadata: Metadata = {
title: 'Order Confirmed',
description: 'You purchase was successful',
};
export default async function OrderConfirmedPage(props: Props) {
const params = await props.params;
const order = await retrieveOrder(params.id).catch(() => null);
if (!order) {
return notFound();
}
return <OrderCompletedTemplate order={order} />;
}

View File

@@ -0,0 +1,42 @@
import { Heading, Text } from '@medusajs/ui';
import { acceptTransferRequest } from '~/medusa/lib/data/orders';
import TransferImage from '~/medusa/modules/order/components/transfer-image';
export default async function TransferPage({
params,
}: {
params: { id: string; token: string };
}) {
const { id, token } = params;
const { success, error } = await acceptTransferRequest(id, token);
return (
<div className="mx-auto mt-10 mb-20 flex w-2/5 flex-col items-start gap-y-4">
<TransferImage />
<div className="flex flex-col gap-y-6">
{success && (
<>
<Heading level="h1" className="text-xl text-zinc-900">
Order transfered!
</Heading>
<Text className="text-zinc-600">
Order {id} has been successfully transfered to the new owner.
</Text>
</>
)}
{!success && (
<>
<Text className="text-zinc-600">
There was an error accepting the transfer. Please try again.
</Text>
{error && (
<Text className="text-red-500">Error message: {error}</Text>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { Heading, Text } from '@medusajs/ui';
import { declineTransferRequest } from '~/medusa/lib/data/orders';
import TransferImage from '~/medusa/modules/order/components/transfer-image';
export default async function TransferPage({
params,
}: {
params: { id: string; token: string };
}) {
const { id, token } = params;
const { success, error } = await declineTransferRequest(id, token);
return (
<div className="mx-auto mt-10 mb-20 flex w-2/5 flex-col items-start gap-y-4">
<TransferImage />
<div className="flex flex-col gap-y-6">
{success && (
<>
<Heading level="h1" className="text-xl text-zinc-900">
Order transfer declined!
</Heading>
<Text className="text-zinc-600">
Transfer of order {id} has been successfully declined.
</Text>
</>
)}
{!success && (
<>
<Text className="text-zinc-600">
There was an error declining the transfer. Please try again.
</Text>
{error && (
<Text className="text-red-500">Error message: {error}</Text>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { Heading, Text } from '@medusajs/ui';
import TransferActions from '~/medusa/modules/order/components/transfer-actions';
import TransferImage from '~/medusa/modules/order/components/transfer-image';
export default async function TransferPage({
params,
}: {
params: { id: string; token: string };
}) {
const { id, token } = params;
return (
<div className="mx-auto mt-10 mb-20 flex w-2/5 flex-col items-start gap-y-4">
<TransferImage />
<div className="flex flex-col gap-y-6">
<Heading level="h1" className="text-xl text-zinc-900">
Transfer request for order {id}
</Heading>
<Text className="text-zinc-600">
You&#39;ve received a request to transfer ownership of your order (
{id}). If you agree to this request, you can approve the transfer by
clicking the button below.
</Text>
<div className="h-px w-full bg-zinc-200" />
<Text className="text-zinc-600">
If you accept, the new owner will take over all responsibilities and
permissions associated with this order.
</Text>
<Text className="text-zinc-600">
If you do not recognize this request or wish to retain ownership, no
further action is required.
</Text>
<div className="h-px w-full bg-zinc-200" />
<TransferActions id={id} token={token} />
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { Metadata } from 'next';
import { getRegion, listCollections } from '~/medusa/lib/data';
import FeaturedProducts from '~/medusa/modules/home/components/featured-products';
import Hero from '~/medusa/modules/home/components/hero';
export const metadata: Metadata = {
title: 'Medusa Next.js Starter Template',
description:
'A performant frontend ecommerce starter template with Next.js 15 and Medusa.',
};
export default async function Home(props: {
params: Promise<{ countryCode: string }>;
}) {
const params = await props.params;
const { countryCode } = params;
const region = await getRegion(countryCode);
const { collections } = await listCollections({
fields: 'id, handle, title',
});
if (!collections || !region) {
return null;
}
return (
<>
<Hero />
<div className="py-12">
<ul className="flex flex-col gap-x-6">
<FeaturedProducts collections={collections} region={region} />
</ul>
</div>
</>
);
}

View File

@@ -0,0 +1,108 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { listProducts } from '~/medusa/lib/data/products';
import { getRegion, listRegions } from '~/medusa/lib/data/regions';
import ProductTemplate from '~/medusa/modules/products/templates';
type Props = {
params: Promise<{ countryCode: string; handle: string }>;
};
export async function generateStaticParams() {
try {
const countryCodes = await listRegions().then((regions) =>
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat(),
);
if (!countryCodes) {
return [];
}
const promises = countryCodes.map(async (country) => {
const { response } = await listProducts({
countryCode: country,
queryParams: { limit: 100, fields: 'handle' },
});
return {
country,
products: response.products,
};
});
const countryProducts = await Promise.all(promises);
return countryProducts
.flatMap((countryData) =>
countryData.products.map((product) => ({
countryCode: countryData.country,
handle: product.handle,
})),
)
.filter((param) => param.handle);
} catch (error) {
console.error(
`Failed to generate static paths for product pages: ${
error instanceof Error ? error.message : 'Unknown error'
}.`,
);
return [];
}
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params;
const { handle } = params;
const region = await getRegion(params.countryCode);
if (!region) {
notFound();
}
const product = await listProducts({
countryCode: params.countryCode,
queryParams: { handle },
}).then(({ response }) => response.products[0]);
if (!product) {
notFound();
}
return {
title: `${product.title} | Medusa Store`,
description: `${product.title}`,
openGraph: {
title: `${product.title} | Medusa Store`,
description: `${product.title}`,
images: product.thumbnail ? [product.thumbnail] : [],
},
};
}
export default async function ProductPage(props: Props) {
const params = await props.params;
const region = await getRegion(params.countryCode);
if (!region) {
notFound();
}
const pricedProduct = await listProducts({
countryCode: params.countryCode,
queryParams: { handle: params.handle },
}).then(({ response }) => response.products[0]);
if (!pricedProduct) {
notFound();
}
return (
<ProductTemplate
product={pricedProduct}
region={region}
countryCode={params.countryCode}
/>
);
}

View File

@@ -0,0 +1,33 @@
import { Metadata } from 'next';
import { SortOptions } from '~/medusa/modules/store/components/refinement-list/sort-products';
import StoreTemplate from '~/medusa/modules/store/templates';
export const metadata: Metadata = {
title: 'Store',
description: 'Explore all of our products.',
};
type Params = {
searchParams: Promise<{
sortBy?: SortOptions;
page?: string;
}>;
params: Promise<{
countryCode: string;
}>;
};
export default async function StorePage(props: Params) {
const params = await props.params;
const searchParams = await props.searchParams;
const { sortBy, page } = searchParams;
return (
<StoreTemplate
sortBy={sortBy}
page={page}
countryCode={params.countryCode}
/>
);
}

29
app/store/not-found.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { Metadata } from 'next';
import Link from 'next/link';
import { ArrowUpRightMini } from '@medusajs/icons';
import { Text } from '@medusajs/ui';
export const metadata: Metadata = {
title: '404',
description: 'Something went wrong',
};
export default function NotFound() {
return (
<div className="flex min-h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The page you tried to access does not exist.
</p>
<Link className="group flex items-center gap-x-1" href="/">
<Text className="text-ui-fg-interactive">Go to frontpage</Text>
<ArrowUpRightMini
className="duration-150 ease-in-out group-hover:rotate-45"
color="var(--fg-interactive)"
/>
</Link>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

BIN
app/store/twitter-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

View File

@@ -0,0 +1,102 @@
"use client";
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { StoreProduct, StoreProductVariant } from '@medusajs/types';
import { Button } from '@medusajs/ui';
import { handleAddToCart } from '@/lib/services/medusaCart.service';
import { PackageHeader } from './package-header';
import { ButtonTooltip } from './ui/button-tooltip';
export interface IAnalysisPackage {
titleKey: string;
price: number;
nrOfAnalyses: number | string;
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 nrOfAnalyses = analysisPackage?.metadata?.nrOfAnalyses ?? 0;
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" 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

@@ -57,11 +57,11 @@ const pathsConfig = PathsSchema.parse({
accountBillingReturn: `/home/[account]/billing/return`,
joinTeam: '/join',
selectPackage: '/select-package',
booking: '/home/booking',
orderAnalysisPackage: '/home/order-analysis-package',
// these routes are added as placeholders and can be changed when the pages are added
booking: '/booking',
myOrders: '/my-orders',
analysisResults: '/analysis-results',
orderAnalysisPackage: '/order-analysis-package',
orderAnalysis: '/order-analysis',
orderHealthAnalysis: '/order-health-analysis',
},

View File

@@ -21,10 +21,10 @@ export async function register() {
* @param err
*/
export const onRequestError: Instrumentation.onRequestError = async (err) => {
const { getServerMonitoringService } = await import('@kit/monitoring/server');
// const { getServerMonitoringService } = await import('@kit/monitoring/server');
const service = await getServerMonitoringService();
// const service = await getServerMonitoringService();
await service.ready();
await service.captureException(err as Error);
// await service.ready();
// await service.captureException(err as Error);
};

View File

@@ -137,6 +137,7 @@ async function syncData() {
for (const analysisGroup of analysisGroups) {
// SAVE ANALYSIS GROUP
const { data: insertedAnalysisGroup, error } = await supabase
.schema('medreport')
.from('analysis_groups')
.upsert(
{
@@ -174,6 +175,7 @@ async function syncData() {
const analysisElement = item.UuringuElement;
const { data: insertedAnalysisElement, error } = await supabase
.schema('medreport')
.from('analysis_elements')
.upsert(
{
@@ -217,6 +219,7 @@ async function syncData() {
if (analyses?.length) {
for (const analysis of analyses) {
const { data: insertedAnalysis, error } = await supabase
.schema('medreport')
.from('analyses')
.upsert(
{
@@ -259,7 +262,7 @@ async function syncData() {
}
}
await supabase.from('codes').upsert(codes);
await supabase.schema('medreport').from('codes').upsert(codes);
await supabase.schema('audit').from('sync_entries').insert({
operation: 'ANALYSES_SYNC',

View File

@@ -105,10 +105,12 @@ async function syncData() {
});
const { error: providersError } = await supabase
.schema('medreport')
.from('connected_online_providers')
.upsert(mappedClinics);
const { error: servicesError } = await supabase
.schema('medreport')
.from('connected_online_services')
.upsert(mappedServices, { onConflict: 'id', ignoreDuplicates: false });

View File

@@ -34,6 +34,9 @@ export const defaultI18nNamespaces = [
'marketing',
'dashboard',
'product',
'booking',
'order-analysis-package',
'cart',
];
/**

View File

@@ -1,5 +1,5 @@
'use server'
'use server';
import logRequestResult from '@/lib/services/audit.service';
import { RequestStatus } from '@/lib/types/audit';
import {
@@ -9,7 +9,7 @@ import {
ConnectedOnlineMethodName,
} from '@/lib/types/connected-online';
import { ExternalApi } from '@/lib/types/external';
import { Tables } from '@/supabase/database.types';
import { Tables } from '@/packages/supabase/src/database.types';
import { createClient } from '@/utils/supabase/server';
import axios from 'axios';
@@ -106,11 +106,13 @@ export async function bookAppointment(
{ data: dbService, error: serviceError },
] = await Promise.all([
supabase
.schema('medreport')
.from('connected_online_providers')
.select('*')
.eq('id', clinicId)
.limit(1),
supabase
.schema('medreport')
.from('connected_online_services')
.select('*')
.eq('sync_id', serviceSyncId)
@@ -132,8 +134,14 @@ export async function bookAppointment(
);
}
const clinic: Tables<'connected_online_providers'> = dbClinic![0];
const service: Tables<'connected_online_services'> = dbService![0];
const clinic: Tables<
{ schema: 'medreport' },
'connected_online_providers'
> = dbClinic![0];
const service: Tables<
{ schema: 'medreport' },
'connected_online_services'
> = dbService![0];
// TODO the dummy data needs to be replaced with real values once they're present on the user/account
const response = await axios.post(
@@ -183,6 +191,7 @@ export async function bookAppointment(
const responseParts = responseData.Value.split(',');
const { error } = await supabase
.schema('medreport')
.from('connected_online_reservation')
.insert({
booking_code: responseParts[1],

View File

@@ -32,6 +32,7 @@ import { toArray } from '@/lib/utils';
import axios from 'axios';
import { XMLParser } from 'fast-xml-parser';
import { uniqBy } from 'lodash';
import { Tables } from '@kit/supabase/database';
const BASE_URL = process.env.MEDIPOST_URL!;
@@ -196,6 +197,7 @@ async function saveAnalysisGroup(
supabase: SupabaseClient,
) {
const { data: insertedAnalysisGroup, error } = await supabase
.schema('medreport')
.from('analysis_groups')
.upsert(
{
@@ -215,13 +217,14 @@ async function saveAnalysisGroup(
const analysisGroupId = insertedAnalysisGroup[0].id;
const analysisGroupCodes = toArray(analysisGroup.Kood);
const codes: Partial<Tables<'codes'>>[] = analysisGroupCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: analysisGroupId,
}));
const codes: Partial<Tables<{ schema: 'medreport' }, 'codes'>>[] =
analysisGroupCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: analysisGroupId,
}));
const analysisGroupItems = toArray(analysisGroup.Uuring);
@@ -229,6 +232,7 @@ async function saveAnalysisGroup(
const analysisElement = item.UuringuElement;
const { data: insertedAnalysisElement, error } = await supabase
.schema('medreport')
.from('analysis_elements')
.upsert(
{
@@ -270,6 +274,7 @@ async function saveAnalysisGroup(
if (analyses?.length) {
for (const analysis of analyses) {
const { data: insertedAnalysis, error } = await supabase
.schema('medreport')
.from('analyses')
.upsert(
{
@@ -310,6 +315,7 @@ async function saveAnalysisGroup(
}
const { error: codesError } = await supabase
.schema('medreport')
.from('codes')
.upsert(codes, { ignoreDuplicates: false });
@@ -404,34 +410,41 @@ export async function composeOrderXML(
};
const { data: analysisElements } = (await supabase
.schema('medreport')
.from('analysis_elements')
.select(`*, analysis_groups(*)`)
.in('id', orderedElements)) as {
data: ({
analysis_groups: Tables<'analysis_groups'>;
} & Tables<'analysis_elements'>)[];
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
} & Tables<{ schema: 'medreport' }, 'analysis_elements'>)[];
};
const { data: analyses } = (await supabase
.schema('medreport')
.from('analyses')
.select(`*, analysis_elements(*, analysis_groups(*))`)
.in('id', orderedAnalyses)) as {
data: ({
analysis_elements: Tables<'analysis_elements'> & {
analysis_groups: Tables<'analysis_groups'>;
analysis_elements: Tables<
{ schema: 'medreport' },
'analysis_elements'
> & {
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
};
} & Tables<'analyses'>)[];
} & Tables<{ schema: 'medreport' }, 'analyses'>)[];
};
const analysisGroups: Tables<'analysis_groups'>[] = uniqBy(
(
analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ?? []
).concat(
analyses?.flatMap(
({ analysis_elements }) => analysis_elements.analysis_groups,
) ?? [],
),
'id',
);
const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] =
uniqBy(
(
analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ??
[]
).concat(
analyses?.flatMap(
({ analysis_elements }) => analysis_elements.analysis_groups,
) ?? [],
),
'id',
);
const specimenSection = [];
const analysisSection = [];
@@ -545,6 +558,7 @@ export async function syncPrivateMessage(
const status = response.TellimuseOlek;
const { data: analysisOrder, error: analysisOrderError } = await supabase
.schema('medreport')
.from('analysis_orders')
.select('user_id')
.eq('id', response.ValisTellimuseId);
@@ -556,6 +570,7 @@ export async function syncPrivateMessage(
}
const { data: analysisResponse, error } = await supabase
.schema('medreport')
.from('analysis_responses')
.upsert(
{
@@ -576,7 +591,7 @@ export async function syncPrivateMessage(
const analysisGroups = toArray(response.UuringuGrupp);
const responses: Omit<
Tables<'analysis_response_elements'>,
Tables<{ schema: 'medreport' }, 'analysis_response_elements'>,
'id' | 'created_at' | 'updated_at'
>[] = [];
for (const analysisGroup of analysisGroups) {
@@ -608,6 +623,7 @@ export async function syncPrivateMessage(
}
const { error: deleteError } = await supabase
.schema('medreport')
.from('analysis_response_elements')
.delete()
.eq('analysis_response_id', analysisResponse[0].id);
@@ -619,6 +635,7 @@ export async function syncPrivateMessage(
}
const { error: elementInsertError } = await supabase
.schema('medreport')
.from('analysis_response_elements')
.insert(responses);

View File

@@ -0,0 +1,127 @@
'use server';
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart';
import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types';
import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src';
import { headers } from 'next/headers';
import { requireUserInServerComponent } from '../server/require-user-in-server-component';
export async function handleAddToCart({
selectedVariant,
countryCode,
}: {
selectedVariant: StoreProductVariant
countryCode: string
}) {
const supabase = getSupabaseServerClient();
const user = await requireUserInServerComponent();
const account = await loadCurrentUserAccount()
if (!account) {
throw new Error('Account not found');
}
const quantity = 1;
const cart = await addToCart({
variantId: selectedVariant.id,
quantity,
countryCode,
});
const { error } = await supabase
.schema('audit')
.from('cart_entries')
.insert({
variant_id: selectedVariant.id,
operation: 'ADD_TO_CART',
account_id: account.id,
cart_id: cart.id,
changed_by: user.id,
});
if (error) {
throw new Error('Error logging cart entry: ' + error.message);
}
return cart;
}
export async function handleNavigateToPayment({ language }: { language: string }) {
const supabase = getSupabaseServerClient();
const user = await requireUserInServerComponent();
const account = await loadCurrentUserAccount()
if (!account) {
throw new Error('Account not found');
}
const cart = await retrieveCart();
if (!cart) {
throw new Error("No cart found");
}
const headersList = await headers();
const host = "webhook.site:3000";
const proto = "http";
// const host = headersList.get('host');
// const proto = headersList.get('x-forwarded-proto') ?? 'http';
const publicUrl = `${proto}://${host}`;
const paymentLink = await new MontonioOrderHandlerService().getMontonioPaymentLink({
notificationUrl: `${publicUrl}/api/billing/webhook`,
returnUrl: `${publicUrl}/home/cart/montonio-callback`,
amount: cart.total,
currency: cart.currency_code.toUpperCase(),
description: `Order from Medreport`,
locale: language,
merchantReference: `${account.id}:${cart.id}:${Date.now()}`,
});
const { error } = await supabase
.schema('audit')
.from('cart_entries')
.insert({
operation: 'NAVIGATE_TO_PAYMENT',
account_id: account.id,
cart_id: cart.id,
changed_by: user.id,
});
if (error) {
throw new Error('Error logging cart entry: ' + error.message);
}
return paymentLink;
}
export async function handleLineItemTimeout({
lineItem,
}: {
lineItem: StoreCartLineItem
}) {
const supabase = getSupabaseServerClient();
const user = await requireUserInServerComponent();
const account = await loadCurrentUserAccount()
if (!account) {
throw new Error('Account not found');
}
if (lineItem.updated_at) {
const updatedAt = new Date(lineItem.updated_at);
const now = new Date();
const diff = now.getTime() - updatedAt.getTime();
}
await deleteLineItem(lineItem.id);
const { error } = await supabase
.schema('audit')
.from('cart_entries')
.insert({
operation: 'LINE_ITEM_TIMEOUT',
account_id: account.id,
cart_id: lineItem.cart_id,
changed_by: user.id,
});
if (error) {
throw new Error('Error logging cart entry: ' + error.message);
}
}

View File

@@ -1,5 +1,5 @@
import { DATE_TIME_FORMAT } from '@/lib/constants';
import { Tables } from '@/supabase/database.types';
import { Tables } from '@/packages/supabase/src/database.types';
import { format } from 'date-fns';
const isProd = process.env.NODE_ENV === 'production';
@@ -160,7 +160,7 @@ export const getAnalysisGroup = (
analysisGroupOriginalId: string,
analysisGroupName: string,
specimenOrderNr: number,
analysisElement: Tables<'analysis_elements'>,
analysisElement: Tables<{ schema: 'medreport' }, 'analysis_elements'>,
) =>
`<UuringuGrupp>
<UuringuGruppId>${analysisGroupOriginalId}</UuringuGruppId>

View File

@@ -54,6 +54,9 @@
"@makerkit/data-loader-supabase-core": "^0.0.10",
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
"@marsidev/react-turnstile": "^1.1.0",
"@medusajs/icons": "^2.8.6",
"@medusajs/js-sdk": "latest",
"@medusajs/ui": "latest",
"@nosecone/next": "1.0.0-beta.7",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-visually-hidden": "^1.2.3",
@@ -65,6 +68,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"fast-xml-parser": "^5.2.5",
"jsonwebtoken": "9.0.2",
"lodash": "^4.17.21",
"lucide-react": "^0.510.0",
"next": "15.3.2",
@@ -85,8 +89,11 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@medusajs/types": "latest",
"@medusajs/ui-preset": "latest",
"@next/bundle-analyzer": "15.3.2",
"@tailwindcss/postcss": "^4.1.10",
"@types/jsonwebtoken": "9.0.10",
"@types/lodash": "^4.17.17",
"@types/node": "^22.15.32",
"@types/react": "19.1.4",

View File

@@ -20,8 +20,7 @@
"@kit/prettier-config": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"zod": "^3.24.4"
"@kit/ui": "workspace:*"
},
"typesVersions": {
"*": {

View File

@@ -13,6 +13,7 @@ export const BillingProviderSchema = z.enum([
'stripe',
'paddle',
'lemon-squeezy',
'montonio',
]);
export const PaymentTypeSchema = z.enum(['one-time', 'recurring']);

View File

@@ -1,5 +1,37 @@
import { UpsertOrderParams, UpsertSubscriptionParams } from '../types';
export interface IHandleWebhookEventParams {
// this method is called when a checkout session is completed
onCheckoutSessionCompleted: (
subscription: UpsertSubscriptionParams | UpsertOrderParams,
) => Promise<unknown>;
// this method is called when a subscription is updated
onSubscriptionUpdated: (
subscription: UpsertSubscriptionParams,
) => Promise<unknown>;
// this method is called when a subscription is deleted
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
// this method is called when a payment is succeeded. This is used for
// one-time payments
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
// this method is called when a payment is failed. This is used for
// one-time payments
onPaymentFailed: (sessionId: string) => Promise<unknown>;
// this method is called when an invoice is paid. We don't have a specific use case for this
// but it's extremely common for credit-based systems
onInvoicePaid: (
subscription: UpsertSubscriptionParams,
) => Promise<unknown>;
// generic handler for any event
onEvent?: (data: unknown) => Promise<unknown>;
}
/**
* @name BillingWebhookHandlerService
* @description Represents an abstract class for handling billing webhook events.
@@ -20,36 +52,6 @@ export abstract class BillingWebhookHandlerService {
*/
abstract handleWebhookEvent(
event: unknown,
params: {
// this method is called when a checkout session is completed
onCheckoutSessionCompleted: (
subscription: UpsertSubscriptionParams | UpsertOrderParams,
) => Promise<unknown>;
// this method is called when a subscription is updated
onSubscriptionUpdated: (
subscription: UpsertSubscriptionParams,
) => Promise<unknown>;
// this method is called when a subscription is deleted
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
// this method is called when a payment is succeeded. This is used for
// one-time payments
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
// this method is called when a payment is failed. This is used for
// one-time payments
onPaymentFailed: (sessionId: string) => Promise<unknown>;
// this method is called when an invoice is paid. We don't have a specific use case for this
// but it's extremely common for credit-based systems
onInvoicePaid: (
subscription: UpsertSubscriptionParams,
) => Promise<unknown>;
// generic handler for any event
onEvent?: (data: unknown) => Promise<unknown>;
},
params: IHandleWebhookEventParams,
): Promise<unknown>;
}

View File

@@ -1,7 +1,7 @@
import { Database } from '@kit/supabase/database';
export type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'] & {
Database['medreport']['Functions']['upsert_subscription']['Args'] & {
line_items: Array<LineItem>;
};
@@ -19,4 +19,4 @@ interface LineItem {
}
export type UpsertOrderParams =
Database['public']['Functions']['upsert_order']['Args'];
Database['medreport']['Functions']['upsert_order']['Args'];

View File

@@ -23,6 +23,7 @@
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/stripe": "workspace:*",
"@kit/montonio": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
@@ -32,9 +33,7 @@
"lucide-react": "^0.510.0",
"next": "15.3.2",
"react": "19.1.0",
"react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1",
"zod": "^3.24.4"
"react-hook-form": "^7.56.3"
},
"typesVersions": {
"*": {

View File

@@ -14,8 +14,8 @@ import { Trans } from '@kit/ui/trans';
import { CurrentPlanBadge } from './current-plan-badge';
import { LineItemDetails } from './line-item-details';
type Order = Tables<'orders'>;
type LineItem = Tables<'order_items'>;
type Order = Tables<{ schema: 'medreport' }, 'orders'>;
type LineItem = Tables<{ schema: 'medreport' }, 'order_items'>;
interface Props {
order: Order & {

View File

@@ -18,8 +18,8 @@ import { CurrentPlanAlert } from './current-plan-alert';
import { CurrentPlanBadge } from './current-plan-badge';
import { LineItemDetails } from './line-item-details';
type Subscription = Tables<'subscriptions'>;
type LineItem = Tables<'subscription_items'>;
type Subscription = Tables<{ schema: 'medreport' }, 'subscriptions'>;
type LineItem = Tables<{ schema: 'medreport' }, 'subscription_items'>;
interface Props {
subscription: Subscription & {

View File

@@ -30,6 +30,13 @@ export function createBillingEventHandlerFactoryService(
return new StripeWebhookHandlerService(planTypesMap);
});
// Register the Montonio webhook handler
billingWebhookHandlerRegistry.register('montonio', async () => {
const { MontonioWebhookHandlerService } = await import('@kit/montonio');
return new MontonioWebhookHandlerService();
});
// Register the Lemon Squeezy webhook handler
billingWebhookHandlerRegistry.register('lemon-squeezy', async () => {
const { LemonSqueezyWebhookHandlerService } = await import(

View File

@@ -86,6 +86,7 @@ class BillingEventHandlerService {
logger.info(ctx, 'Processing subscription deleted event...');
const { error } = await client
.schema('medreport')
.from('subscriptions')
.delete()
.match({ id: subscriptionId });
@@ -109,7 +110,7 @@ class BillingEventHandlerService {
logger.info(ctx, 'Successfully deleted subscription');
},
onSubscriptionUpdated: async (subscription) => {
const client = this.clientProvider();
const client = this.clientProvider().schema('medreport');
const logger = await getLogger();
const ctx = {
@@ -147,7 +148,7 @@ class BillingEventHandlerService {
onCheckoutSessionCompleted: async (payload) => {
// Handle the checkout session completed event
// here we add the subscription to the database
const client = this.clientProvider();
const client = this.clientProvider().schema('medreport');
const logger = await getLogger();
// Check if the payload contains an order_id
@@ -212,7 +213,7 @@ class BillingEventHandlerService {
}
},
onPaymentSucceeded: async (sessionId: string) => {
const client = this.clientProvider();
const client = this.clientProvider().schema('medreport');
const logger = await getLogger();
const ctx = {
@@ -244,7 +245,7 @@ class BillingEventHandlerService {
logger.info(ctx, 'Successfully updated payment status');
},
onPaymentFailed: async (sessionId: string) => {
const client = this.clientProvider();
const client = this.clientProvider().schema('medreport');
const logger = await getLogger();
const ctx = {

View File

@@ -21,6 +21,7 @@ export async function getBillingGatewayProvider(
async function getBillingProvider(client: SupabaseClient<Database>) {
const { data, error } = await client
.schema('medreport')
.from('config')
.select('billing_provider')
.single();

View File

@@ -4,7 +4,7 @@ import { Tables } from '@kit/supabase/database';
import { createBillingGatewayService } from '../billing-gateway/billing-gateway.service';
type Subscription = Tables<'subscriptions'>;
type Subscription = Tables<{ schema: 'medreport' }, 'subscriptions'>;
export function createBillingWebhooksService() {
return new BillingWebhooksService();

View File

@@ -26,8 +26,7 @@
"@kit/ui": "workspace:*",
"@types/react": "19.1.4",
"next": "15.3.2",
"react": "19.1.0",
"zod": "^3.24.4"
"react": "19.1.0"
},
"typesVersions": {
"*": {

View File

@@ -17,14 +17,14 @@ import { createLemonSqueezySubscriptionPayloadBuilderService } from './lemon-squ
import { createHmac } from './verify-hmac';
type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'] & {
Database['medreport']['Functions']['upsert_subscription']['Args'] & {
line_items: Array<LineItem>;
};
type UpsertOrderParams =
Database['public']['Functions']['upsert_order']['Args'];
Database['medreport']['Functions']['upsert_order']['Args'];
type BillingProvider = Enums<'billing_provider'>;
type BillingProvider = Enums<{ schema: 'medreport' }, 'billing_provider'>;
interface LineItem {
id: string;

View File

@@ -0,0 +1,4 @@
# Billing / Montonio - @kit/montonio
This package is responsible for handling all billing related operations using Montonio.

Some files were not shown because too many files have changed in this diff Show More