Compare commits
27 Commits
10580fa653
...
MED-122-v2
| Author | SHA1 | Date | |
|---|---|---|---|
| ab0834149d | |||
| 5487242bbe | |||
| 7ccc45ce77 | |||
| 25b4e06b89 | |||
| 55869ea16f | |||
| 736194bb0b | |||
| 6426e2a79b | |||
| ea3fb22f1d | |||
| 02bb9f7d34 | |||
| 00b079e170 | |||
| 1d0808018b | |||
| 7b71da3825 | |||
| ad213dd4f8 | |||
| 0a0b1f0dee | |||
| bbcf0b6d83 | |||
|
|
0af3823148 | ||
|
|
23b54bb4f4 | ||
|
|
c5ddccc15d | ||
|
|
023bc897c2 | ||
|
|
d9198a8a12 | ||
|
|
0b8fadb771 | ||
|
|
9371ff7710 | ||
|
|
4f36f9c037 | ||
|
|
29ff8cb512 | ||
|
|
c0a5238e19 | ||
|
|
7bf5dd8899 | ||
|
|
2e62e4b0eb |
23
.env
23
.env
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
106
app/api/montonio/verify-token/route.ts
Normal file
106
app/api/montonio/verify-token/route.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
@@ -8,4 +8,4 @@ async function SiteLayout(props: React.PropsWithChildren) {
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(SiteLayout);
|
||||
export default SiteLayout;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
34
app/home/(user)/(dashboard)/booking/page.tsx
Normal file
34
app/home/(user)/(dashboard)/booking/page.tsx
Normal 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);
|
||||
5
app/home/(user)/(dashboard)/cart/loading.tsx
Normal file
5
app/home/(user)/(dashboard)/cart/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import SkeletonCartPage from '~/medusa/modules/skeletons/templates/skeleton-cart-page';
|
||||
|
||||
export default function Loading() {
|
||||
return <SkeletonCartPage />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
47
app/home/(user)/(dashboard)/cart/page.tsx
Normal file
47
app/home/(user)/(dashboard)/cart/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'}>
|
||||
|
||||
46
app/home/(user)/(dashboard)/order-analysis-package/page.tsx
Normal file
46
app/home/(user)/(dashboard)/order-analysis-package/page.tsx
Normal 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);
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
48
app/home/(user)/_components/cart/cart-item-delete.tsx
Normal file
48
app/home/(user)/_components/cart/cart-item-delete.tsx
Normal 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;
|
||||
56
app/home/(user)/_components/cart/cart-item.tsx
Normal file
56
app/home/(user)/_components/cart/cart-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
app/home/(user)/_components/cart/cart-items.tsx
Normal file
54
app/home/(user)/_components/cart/cart-items.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
app/home/(user)/_components/cart/cart-timer.tsx
Normal file
91
app/home/(user)/_components/cart/cart-timer.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
166
app/home/(user)/_components/cart/discount-code.tsx
Normal file
166
app/home/(user)/_components/cart/discount-code.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
app/home/(user)/_components/cart/index.tsx
Normal file
111
app/home/(user)/_components/cart/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
app/home/(user)/_components/cart/montonio-checkout-callback.tsx
Normal file
101
app/home/(user)/_components/cart/montonio-checkout-callback.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
77
app/home/(user)/_components/order-cards.tsx
Normal file
77
app/home/(user)/_components/order-cards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
app/home/(user)/_components/order/cart-totals.tsx
Normal file
82
app/home/(user)/_components/order/cart-totals.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
app/home/(user)/_components/order/order-completed.tsx
Normal file
27
app/home/(user)/_components/order/order-completed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
app/home/(user)/_components/order/order-details.tsx
Normal file
47
app/home/(user)/_components/order/order-details.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
app/home/(user)/_components/order/order-item.tsx
Normal file
52
app/home/(user)/_components/order/order-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
app/home/(user)/_components/order/order-items.tsx
Normal file
39
app/home/(user)/_components/order/order-items.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
app/home/(user)/_lib/server/load-analysis-packages.ts
Normal file
36
app/home/(user)/_lib/server/load-analysis-packages.ts
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -35,13 +35,13 @@ async function workspaceLoader() {
|
||||
accountsPromise(),
|
||||
workspacePromise,
|
||||
requireUserInServerComponent(),
|
||||
tempAccountsPromise()
|
||||
tempAccountsPromise(),
|
||||
]);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
workspace,
|
||||
user,
|
||||
tempVisibleAccounts
|
||||
tempVisibleAccounts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
30
app/store/[countryCode]/(checkout)/checkout/page.tsx
Normal file
30
app/store/[countryCode]/(checkout)/checkout/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
app/store/[countryCode]/(checkout)/layout.tsx
Normal file
45
app/store/[countryCode]/(checkout)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
app/store/[countryCode]/(checkout)/not-found.tsx
Normal file
20
app/store/[countryCode]/(checkout)/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
23
app/store/[countryCode]/(main)/account/@dashboard/page.tsx
Normal file
23
app/store/[countryCode]/(main)/account/@dashboard/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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" />;
|
||||
};
|
||||
``;
|
||||
12
app/store/[countryCode]/(main)/account/@login/page.tsx
Normal file
12
app/store/[countryCode]/(main)/account/@login/page.tsx
Normal 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 />;
|
||||
}
|
||||
21
app/store/[countryCode]/(main)/account/layout.tsx
Normal file
21
app/store/[countryCode]/(main)/account/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
app/store/[countryCode]/(main)/account/loading.tsx
Normal file
9
app/store/[countryCode]/(main)/account/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
app/store/[countryCode]/(main)/cart/loading.tsx
Normal file
5
app/store/[countryCode]/(main)/cart/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import SkeletonCartPage from '~/medusa/modules/skeletons/templates/skeleton-cart-page';
|
||||
|
||||
export default function Loading() {
|
||||
return <SkeletonCartPage />;
|
||||
}
|
||||
21
app/store/[countryCode]/(main)/cart/not-found.tsx
Normal file
21
app/store/[countryCode]/(main)/cart/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
app/store/[countryCode]/(main)/cart/page.tsx
Normal file
23
app/store/[countryCode]/(main)/cart/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
95
app/store/[countryCode]/(main)/collections/[handle]/page.tsx
Normal file
95
app/store/[countryCode]/(main)/collections/[handle]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
app/store/[countryCode]/(main)/layout.tsx
Normal file
46
app/store/[countryCode]/(main)/layout.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
app/store/[countryCode]/(main)/not-found.tsx
Normal file
20
app/store/[countryCode]/(main)/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import SkeletonOrderConfirmed from '~/medusa/modules/skeletons/templates/skeleton-order-confirmed';
|
||||
|
||||
export default function Loading() {
|
||||
return <SkeletonOrderConfirmed />;
|
||||
}
|
||||
25
app/store/[countryCode]/(main)/order/[id]/confirmed/page.tsx
Normal file
25
app/store/[countryCode]/(main)/order/[id]/confirmed/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
40
app/store/[countryCode]/(main)/page.tsx
Normal file
40
app/store/[countryCode]/(main)/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
108
app/store/[countryCode]/(main)/products/[handle]/page.tsx
Normal file
108
app/store/[countryCode]/(main)/products/[handle]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
33
app/store/[countryCode]/(main)/store/page.tsx
Normal file
33
app/store/[countryCode]/(main)/store/page.tsx
Normal 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
29
app/store/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
app/store/opengraph-image.jpg
Normal file
BIN
app/store/opengraph-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 229 KiB |
BIN
app/store/twitter-image.jpg
Normal file
BIN
app/store/twitter-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 229 KiB |
102
components/select-analysis-package.tsx
Normal file
102
components/select-analysis-package.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
components/select-analysis-packages.tsx
Normal file
19
components/select-analysis-packages.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { StoreProduct } from '@medusajs/types';
|
||||
|
||||
import SelectAnalysisPackage from './select-analysis-package';
|
||||
|
||||
export default function SelectAnalysisPackages({ analysisPackages, countryCode }: { analysisPackages: StoreProduct[], countryCode: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{analysisPackages.length > 0 ? analysisPackages.map(
|
||||
(product) => (
|
||||
<SelectAnalysisPackage key={product.title} analysisPackage={product} countryCode={countryCode} />
|
||||
)) : (
|
||||
<h4>
|
||||
<Trans i18nKey='order-analysis-package:noPackagesAvailable' />
|
||||
</h4>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ export const defaultI18nNamespaces = [
|
||||
'marketing',
|
||||
'dashboard',
|
||||
'product',
|
||||
'booking',
|
||||
'order-analysis-package',
|
||||
'cart',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
127
lib/services/medusaCart.service.ts
Normal file
127
lib/services/medusaCart.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
"*": {
|
||||
|
||||
@@ -13,6 +13,7 @@ export const BillingProviderSchema = z.enum([
|
||||
'stripe',
|
||||
'paddle',
|
||||
'lemon-squeezy',
|
||||
'montonio',
|
||||
]);
|
||||
|
||||
export const PaymentTypeSchema = z.enum(['one-time', 'recurring']);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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": {
|
||||
"*": {
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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": {
|
||||
"*": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
4
packages/billing/montonio/README.md
Normal file
4
packages/billing/montonio/README.md
Normal 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
Reference in New Issue
Block a user