12 Commits

Author SHA1 Message Date
danelkungla
0af3823148 FIX Build
Fix build
2025-07-09 14:02:53 +03:00
Danel Kungla
23b54bb4f4 Merge branch 'main' into FIX-BUILD 2025-07-09 14:02:10 +03:00
Danel Kungla
c5ddccc15d fix build 2025-07-09 14:01:43 +03:00
danelkungla
023bc897c2 MED-63: change api to medreport schema
MED-63
* membership confirmation flow
* update schema public -> medreport
2025-07-09 13:40:28 +03:00
Danel Kungla
d9198a8a12 add medreport schema 2025-07-09 13:31:37 +03:00
Danel Kungla
0b8fadb771 remove medreport product migration and related constraints 2025-07-09 10:01:39 +03:00
Danel Kungla
9371ff7710 feat: update API and database structure for membership confirmation and wallet integration
- Refactor API calls to use 'medreport' schema for membership confirmation and account updates
- Enhance success notification component to include wallet balance and expiration details
- Modify database types to reflect new 'medreport' schema and add product-related tables
- Update localization files to support new wallet messages
- Adjust SQL migration scripts for proper schema references and function definitions
2025-07-09 10:01:12 +03:00
Danel Kungla
4f36f9c037 refactor: clean up imports and enhance error logging in user workspace and team invitations actions 2025-07-08 18:34:21 +03:00
Danel Kungla
29ff8cb512 fix pnpm-lock 2025-07-08 16:06:39 +03:00
Danel Kungla
c0a5238e19 Merge branch 'main' into MED-63 2025-07-08 16:06:27 +03:00
danelkungla
7bf5dd8899 Merge pull request #31 from MR-medreport/B2B-26
B2B-26: move selfservice tables to medreport schema and add medusa store
2025-07-08 15:56:30 +03:00
Danel Kungla
2e62e4b0eb move selfservice tables to medreport schema
add base medusa store frontend
2025-07-07 13:46:22 +03:00
309 changed files with 25892 additions and 934 deletions

5
.env
View File

@@ -51,4 +51,7 @@ 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=

View File

@@ -16,4 +16,6 @@ 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=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,6 @@
import { use } from 'react';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
@@ -6,9 +9,7 @@ import { withI18n } from '~/lib/i18n/with-i18n';
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';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,6 +54,9 @@
"@makerkit/data-loader-supabase-core": "^0.0.10",
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
"@marsidev/react-turnstile": "^1.1.0",
"@medusajs/icons": "^2.8.6",
"@medusajs/js-sdk": "latest",
"@medusajs/ui": "latest",
"@nosecone/next": "1.0.0-beta.7",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-visually-hidden": "^1.2.3",
@@ -85,6 +88,8 @@
"@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/lodash": "^4.17.17",

View File

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

View File

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

View File

@@ -32,9 +32,7 @@
"lucide-react": "^0.510.0",
"next": "15.3.2",
"react": "19.1.0",
"react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1",
"zod": "^3.24.4"
"react-hook-form": "^7.56.3"
},
"typesVersions": {
"*": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,8 +30,7 @@
"@types/react": "19.1.4",
"date-fns": "^4.1.0",
"next": "15.3.2",
"react": "19.1.0",
"zod": "^3.24.4"
"react": "19.1.0"
},
"typesVersions": {
"*": {

View File

@@ -9,7 +9,7 @@ import { createStripeClient } from './stripe-sdk';
import { createStripeSubscriptionPayloadBuilderService } from './stripe-subscription-payload-builder.service';
type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'] & {
Database['medreport']['Functions']['upsert_subscription']['Args'] & {
line_items: Array<LineItem>;
};
@@ -27,9 +27,9 @@ interface 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'>;
export class StripeWebhookHandlerService
implements BillingWebhookHandlerService

View File

@@ -28,8 +28,7 @@
"@kit/ui": "workspace:*",
"@types/node": "^22.15.18",
"@types/react": "19.1.4",
"react": "19.1.0",
"zod": "^3.24.4"
"react": "19.1.0"
},
"typesVersions": {
"*": {

View File

@@ -22,8 +22,7 @@
"@kit/supabase": "workspace:*",
"@kit/team-accounts": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@supabase/supabase-js": "2.49.4",
"zod": "^3.24.4"
"@supabase/supabase-js": "2.49.4"
},
"typesVersions": {
"*": {

View File

@@ -1,6 +1,6 @@
import { Database } from '@kit/supabase/database';
export type Tables = Database['public']['Tables'];
export type Tables = Database['medreport']['Tables'];
export type TableChangeType = 'INSERT' | 'UPDATE' | 'DELETE';

View File

@@ -44,9 +44,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1",
"sonner": "^2.0.3",
"zod": "^3.24.4"
"sonner": "^2.0.3"
},
"prettier": "@kit/prettier-config",
"typesVersions": {

View File

@@ -19,7 +19,8 @@ import { Trans } from '@kit/ui/trans';
import { useUpdateAccountData } from '../../hooks/use-update-account';
import { AccountDetailsSchema } from '../../schema/account-details.schema';
type UpdateUserDataParams = Database['public']['Tables']['accounts']['Update'];
type UpdateUserDataParams =
Database['medreport']['Tables']['accounts']['Update'];
export function UpdateAccountDetailsForm({
displayName,

View File

@@ -72,6 +72,7 @@ function UploadProfileAvatarForm(props: {
uploadUserProfilePhoto(client, file, props.userId)
.then((pictureUrl) => {
return client
.schema('medreport')
.from('accounts')
.update({
picture_url: pictureUrl,
@@ -90,6 +91,7 @@ function UploadProfileAvatarForm(props: {
removeExistingStorageFile()
.then(() => {
return client
.schema('medreport')
.from('accounts')
.update({
picture_url: null,

View File

@@ -17,7 +17,9 @@ interface UserWorkspace {
id: string | null;
name: string | null;
picture_url: string | null;
subscription_status: Tables<'subscriptions'>['status'] | null;
subscription_status:
| Tables<{ schema: 'medreport' }, 'subscriptions'>['status']
| null;
};
user: User;

View File

@@ -21,6 +21,7 @@ export function usePersonalAccountData(
}
const response = await client
.schema('medreport')
.from('accounts')
.select()
.eq('primary_owner_user_id', userId)

View File

@@ -3,7 +3,7 @@ import { useMutation } from '@tanstack/react-query';
import { Database } from '@kit/supabase/database';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
type UpdateData = Database['public']['Tables']['accounts']['Update'];
type UpdateData = Database['medreport']['Tables']['accounts']['Update'];
export function useUpdateAccountData(accountId: string) {
const client = useSupabase();
@@ -11,9 +11,13 @@ export function useUpdateAccountData(accountId: string) {
const mutationKey = ['account:data', accountId];
const mutationFn = async (data: UpdateData) => {
const response = await client.from('accounts').update(data).match({
id: accountId,
});
const response = await client
.schema('medreport')
.from('accounts')
.update(data)
.match({
id: accountId,
});
if (response.error) {
throw response.error;

View File

@@ -17,6 +17,7 @@ class AccountsApi {
*/
async getAccount(id: string) {
const { data, error } = await this.client
.schema('medreport')
.from('accounts')
.select('*')
.eq('id', id)
@@ -35,6 +36,7 @@ class AccountsApi {
*/
async getAccountWorkspace() {
const { data, error } = await this.client
.schema('medreport')
.from('user_account_workspace')
.select(`*`)
.single();
@@ -56,39 +58,43 @@ class AccountsApi {
const { data, error: userError } = authUser;
if (userError) {
console.error('Failed to get user', userError);
throw userError;
}
const { user } = data;
const { data: accounts, error } = await this.client
.schema('medreport')
.from('accounts_memberships')
.select(
`
account_id,
user_accounts (
name,
slug,
picture_url,
)
`,
account_id,
accounts (
name,
slug,
picture_url
)
`,
)
.eq('user_id', user.id)
.eq('account_role', 'owner');
if (error) {
console.error('error', error);
throw error;
}
return accounts.map(({ user_accounts }) => ({
label: user_accounts.name,
value: user_accounts.slug,
image: user_accounts.picture_url,
return accounts.map(({ accounts }) => ({
label: accounts.name,
value: accounts.slug,
image: accounts.picture_url,
}));
}
async loadTempUserAccounts() {
const { data: accounts, error } = await this.client
.schema('medreport')
.from('user_accounts')
.select(`name, slug`);
@@ -129,6 +135,7 @@ class AccountsApi {
*/
async getOrder(accountId: string) {
const response = await this.client
.schema('medreport')
.from('orders')
.select('*, items: order_items !inner (*)')
.eq('account_id', accountId)
@@ -149,6 +156,7 @@ class AccountsApi {
*/
async getCustomerId(accountId: string) {
const response = await this.client
.schema('medreport')
.from('billing_customers')
.select('customer_id')
.eq('account_id', accountId)

View File

@@ -28,8 +28,7 @@
"next": "15.3.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.56.3",
"zod": "^3.24.4"
"react-hook-form": "^7.56.3"
},
"exports": {
".": "./src/index.ts",

View File

@@ -3,6 +3,11 @@ import { BadgeX, Ban, ShieldPlus, VenetianMask } from 'lucide-react';
import { Tables } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
AccountInvitationsTable,
AccountMembersTable,
InviteMembersDialogContainer,
} from '@kit/team-accounts/components';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Badge } from '@kit/ui/badge';
@@ -28,14 +33,8 @@ import { AdminMembersTable } from './admin-members-table';
import { AdminMembershipsTable } from './admin-memberships-table';
import { AdminReactivateUserDialog } from './admin-reactivate-user-dialog';
import {
AccountInvitationsTable,
AccountMembersTable,
InviteMembersDialogContainer,
} from '@kit/team-accounts/components';
type Account = Tables<'accounts'>;
type Membership = Tables<'accounts_memberships'>;
type Account = Tables<{ schema: 'medreport' }, 'accounts'>;
type Membership = Tables<{ schema: 'medreport' }, 'accounts_memberships'>;
export function AdminAccountPage(props: {
account: Account & { memberships: Membership[] };
@@ -231,6 +230,7 @@ async function SubscriptionsTable(props: { accountId: string }) {
const client = getSupabaseServerClient();
const { data: subscription, error } = await client
.schema('medreport')
.from('subscriptions')
.select('*, subscription_items !inner (*)')
.eq('account_id', props.accountId)
@@ -372,6 +372,7 @@ async function getMemberships(userId: string) {
const client = getSupabaseServerClient();
const memberships = await client
.schema('medreport')
.from('accounts_memberships')
.select<
string,
@@ -394,7 +395,7 @@ async function getMemberships(userId: string) {
async function getMembers(accountSlug: string) {
const client = getSupabaseServerClient();
const members = await client.rpc('get_account_members', {
const members = await client.schema('medreport').rpc('get_account_members', {
account_slug: accountSlug,
});

View File

@@ -38,7 +38,7 @@ import { AdminDeleteUserDialog } from './admin-delete-user-dialog';
import { AdminImpersonateUserDialog } from './admin-impersonate-user-dialog';
import { AdminResetPasswordDialog } from './admin-reset-password-dialog';
type Account = Database['public']['Tables']['accounts']['Row'];
type Account = Database['medreport']['Tables']['accounts']['Row'];
const FiltersSchema = z.object({
type: z.enum(['all', 'team', 'personal']),

View File

@@ -9,7 +9,7 @@ import { DataTable } from '@kit/ui/enhanced-data-table';
import { ProfileAvatar } from '@kit/ui/profile-avatar';
type Memberships =
Database['public']['Functions']['get_account_members']['Returns'][number];
Database['medreport']['Functions']['get_account_members']['Returns'][number];
export function AdminMembersTable(props: { members: Memberships[] }) {
return <DataTable data={props.members} columns={getColumns()} />;

View File

@@ -7,7 +7,7 @@ import { ColumnDef } from '@tanstack/react-table';
import { Tables } from '@kit/supabase/database';
import { DataTable } from '@kit/ui/enhanced-data-table';
type Membership = Tables<'accounts_memberships'> & {
type Membership = Tables<{ schema: 'medreport' }, 'accounts_memberships'> & {
account: {
id: string;
name: string;

View File

@@ -15,13 +15,13 @@ import {
ImpersonateUserSchema,
ReactivateUserSchema,
} from './schema/admin-actions.schema';
import { CreateCompanySchema } from './schema/create-company.schema';
import { CreateUserSchema } from './schema/create-user.schema';
import { ResetPasswordSchema } from './schema/reset-password.schema';
import { createAdminAccountsService } from './services/admin-accounts.service';
import { createAdminAuthUserService } from './services/admin-auth-user.service';
import { adminAction } from './utils/admin-action';
import { CreateCompanySchema } from './schema/create-company.schema';
import { createCreateCompanyAccountService } from './services/admin-create-company-account.service';
import { adminAction } from './utils/admin-action';
/**
* @name banUserAction
@@ -183,12 +183,16 @@ export const createUserAction = adminAction(
);
const { error: accountError } = await adminClient
.schema('medreport')
.from('accounts')
.update({ personal_code: personalCode })
.eq('id', data.user.id);
if (accountError) {
logger.error({ accountError }, 'Error inserting personal code to accounts');
logger.error(
{ accountError },
'Error inserting personal code to accounts',
);
throw new Error(`Error saving personal code: ${accountError.message}`);
}

View File

@@ -13,6 +13,7 @@ class AdminAccountsService {
async deleteAccount(accountId: string) {
const { error } = await this.adminClient
.schema('medreport')
.from('accounts')
.delete()
.eq('id', accountId);

View File

@@ -30,6 +30,7 @@ export class AdminDashboardService {
};
const subscriptionsPromise = this.client
.schema('medreport')
.from('subscriptions')
.select('*', selectParams)
.eq('status', 'active')
@@ -47,6 +48,7 @@ export class AdminDashboardService {
});
const trialsPromise = this.client
.schema('medreport')
.from('subscriptions')
.select('*', selectParams)
.eq('status', 'trialing')
@@ -64,6 +66,7 @@ export class AdminDashboardService {
});
const accountsPromise = this.client
.schema('medreport')
.from('accounts')
.select('*', selectParams)
.eq('is_personal_account', true)
@@ -81,6 +84,7 @@ export class AdminDashboardService {
});
const teamAccountsPromise = this.client
.schema('medreport')
.from('accounts')
.select('*', selectParams)
.eq('is_personal_account', false)

View File

@@ -35,9 +35,7 @@
"lucide-react": "^0.510.0",
"next": "15.3.2",
"react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1",
"sonner": "^2.0.3",
"zod": "^3.24.4"
"sonner": "^2.0.3"
},
"prettier": "@kit/prettier-config",
"typesVersions": {

View File

@@ -47,7 +47,7 @@ export function MultiFactorChallengeContainer({
const verifyMFAChallenge = useVerifyMFAChallenge({
onSuccess: () => {
router.replace(paths.redirectPath);
router.replace('/');
},
});

View File

@@ -55,12 +55,11 @@ export function SignInMethodsContainer(props: {
}
try {
const { data: hasConsentPersonalData } = await client.rpc(
'has_consent_personal_data',
{
const { data: hasConsentPersonalData } = await client
.schema('medreport')
.rpc('has_consent_personal_data', {
account_id: userId,
},
);
});
if (hasConsentPersonalData) {
router.replace(props.paths.returnPath);

View File

@@ -25,12 +25,11 @@ class AuthApi {
throw new Error('User not authenticated');
}
const { data, error } = await this.client.rpc(
'has_unseen_membership_confirmation',
{
const { data, error } = await this.client
.schema('medreport')
.rpc('has_unseen_membership_confirmation', {
p_user_id: user.id,
},
);
});
if (error) {
throw error;
@@ -53,15 +52,17 @@ class AuthApi {
throw new Error('User not authenticated');
}
const { error } = await this.client.rpc('update_account', {
p_name: data.firstName,
p_last_name: data.lastName,
p_personal_code: data.personalCode,
p_phone: data.phone || '',
p_city: data.city || '',
p_has_consent_personal_data: data.userConsent,
p_uid: user.id,
});
const { error } = await this.client
.schema('medreport')
.rpc('update_account', {
p_name: data.firstName,
p_last_name: data.lastName,
p_personal_code: data.personalCode,
p_phone: data.phone || '',
p_city: data.city || '',
p_has_consent_personal_data: data.userConsent,
p_uid: user.id,
});
if (error) {
throw error;
@@ -85,12 +86,15 @@ class AuthApi {
if (!user) {
throw new Error('User not authenticated');
}
console.log('test', user, data);
const response = await this.client.from('account_params').insert({
account_id: user.id,
height: data.height,
weight: data.weight,
});
const response = await this.client
.schema('medreport')
.from('account_params')
.insert({
account_id: user.id,
height: data.height,
weight: data.weight,
});
if (response.error) {
throw response.error;

View File

@@ -0,0 +1,125 @@
<p align="center">
<a href="https://www.medusajs.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/59018053/229103275-b5e482bb-4601-46e6-8142-244f531cebdb.svg">
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
<img alt="Medusa logo" src="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
</picture>
</a>
</p>
<h1 align="center">
Medusa Next.js Starter Template
</h1>
<p align="center">
Combine Medusa's modules for your commerce backend with the newest Next.js 15 features for a performant storefront.</p>
<p align="center">
<a href="https://github.com/medusajs/medusa/blob/master/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs welcome!" />
</a>
<a href="https://discord.gg/xpCwq3Kfn8">
<img src="https://img.shields.io/badge/chat-on%20discord-7289DA.svg" alt="Discord Chat" />
</a>
<a href="https://twitter.com/intent/follow?screen_name=medusajs">
<img src="https://img.shields.io/twitter/follow/medusajs.svg?label=Follow%20@medusajs" alt="Follow @medusajs" />
</a>
</p>
### Prerequisites
To use the [Next.js Starter Template](https://medusajs.com/nextjs-commerce/), you should have a Medusa server running locally on port 9000.
For a quick setup, run:
```shell
npx create-medusa-app@latest
```
Check out [create-medusa-app docs](https://docs.medusajs.com/learn/installation) for more details and troubleshooting.
# Overview
The Medusa Next.js Starter is built with:
- [Next.js](https://nextjs.org/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Typescript](https://www.typescriptlang.org/)
- [Medusa](https://medusajs.com/)
Features include:
- Full ecommerce support:
- Product Detail Page
- Product Overview Page
- Product Collections
- Cart
- Checkout with Stripe
- User Accounts
- Order Details
- Full Next.js 15 support:
- App Router
- Next fetching/caching
- Server Components
- Server Actions
- Streaming
- Static Pre-Rendering
# Quickstart
### Setting up the environment variables
Navigate into your projects directory and get your environment variables ready:
```shell
cd nextjs-starter-medusa/
mv .env.template .env.local
```
### Install dependencies
Use Yarn to install all dependencies.
```shell
yarn
```
### Start developing
You are now ready to start up your project.
```shell
yarn dev
```
### Open the code and start customizing
Your site is now running at http://localhost:8000!
# Payment integrations
By default this starter supports the following payment integrations
- [Stripe](https://stripe.com/)
To enable the integrations you need to add the following to your `.env.local` file:
```shell
NEXT_PUBLIC_STRIPE_KEY=<your-stripe-public-key>
```
You'll also need to setup the integrations in your Medusa server. See the [Medusa documentation](https://docs.medusajs.com) for more information on how to configure [Stripe](https://docs.medusajs.com/resources/commerce-modules/payment/payment-provider/stripe#main).
# Resources
## Learn more about Medusa
- [Website](https://www.medusajs.com/)
- [GitHub](https://github.com/medusajs)
- [Documentation](https://docs.medusajs.com/)
## Learn more about Next.js
- [Website](https://nextjs.org/)
- [GitHub](https://github.com/vercel/next.js)
- [Documentation](https://nextjs.org/docs)

View File

@@ -0,0 +1,39 @@
const c = require("ansi-colors")
const requiredEnvs = [
{
key: "NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
// TODO: we need a good doc to point this to
description:
"Learn how to create a publishable key: https://docs.medusajs.com/v2/resources/storefront-development/publishable-api-keys",
},
]
function checkEnvVariables() {
const missingEnvs = requiredEnvs.filter(function (env) {
return !process.env[env.key]
})
if (missingEnvs.length > 0) {
console.error(
c.red.bold("\n🚫 Error: Missing required environment variables\n")
)
missingEnvs.forEach(function (env) {
console.error(c.yellow(` ${c.bold(env.key)}`))
if (env.description) {
console.error(c.dim(` ${env.description}\n`))
}
})
console.error(
c.yellow(
"\nPlease set these variables in your .env file or environment before starting the application.\n"
)
)
process.exit(1)
}
}
module.exports = checkEnvVariables

View File

@@ -0,0 +1,60 @@
{
"name": "medusa-next",
"version": "1.0.3",
"private": true,
"author": "Kasper Fabricius Kristensen <kasper@medusajs.com> & Victor Gerbrands <victor@medusajs.com> (https://www.medusajs.com)",
"description": "Next.js Starter to be used with Medusa V2",
"keywords": [
"medusa-storefront"
],
"scripts": {
"dev": "next dev --turbopack -p 8000",
"build": "next build",
"start": "next start -p 8000",
"lint": "next lint",
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@headlessui/react": "^2.2.0",
"@medusajs/js-sdk": "latest",
"@medusajs/ui": "latest",
"@radix-ui/react-accordion": "^1.2.1",
"@stripe/react-stripe-js": "^1.7.2",
"@stripe/stripe-js": "^1.29.0",
"lodash": "^4.17.21",
"next": "^15.3.1",
"pg": "^8.11.3",
"qs": "^6.12.1",
"react": "19.0.0-rc-66855b96-20241106",
"react-country-flag": "^3.1.0",
"react-dom": "19.0.0-rc-66855b96-20241106",
"server-only": "^0.0.1",
"tailwindcss-radix": "^2.8.0",
"webpack": "^5"
},
"devDependencies": {
"@babel/core": "^7.17.5",
"@medusajs/types": "latest",
"@medusajs/ui-preset": "latest",
"@types/lodash": "^4.14.195",
"@types/node": "17.0.21",
"@types/pg": "^8.11.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-instantsearch-dom": "^6.12.3",
"ansi-colors": "^4.1.3",
"autoprefixer": "^10.4.2",
"babel-loader": "^8.2.3",
"eslint": "8.10.0",
"eslint-config-next": "15.0.3",
"postcss": "^8.4.8",
"prettier": "^2.8.8",
"tailwindcss": "^3.0.23",
"typescript": "^5.3.2"
},
"packageManager": "yarn@3.2.3",
"overrides": {
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106"
}
}

View File

@@ -0,0 +1,14 @@
import Medusa from "@medusajs/js-sdk"
// Defaults to standard port for Medusa server
let MEDUSA_BACKEND_URL = "http://localhost:9000"
if (process.env.MEDUSA_BACKEND_URL) {
MEDUSA_BACKEND_URL = process.env.MEDUSA_BACKEND_URL
}
export const sdk = new Medusa({
baseUrl: MEDUSA_BACKEND_URL,
debug: process.env.NODE_ENV === "development",
publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
})

View File

@@ -0,0 +1,68 @@
import React from "react"
import { CreditCard } from "@medusajs/icons"
import Ideal from "@modules/common/icons/ideal"
import Bancontact from "@modules/common/icons/bancontact"
import PayPal from "@modules/common/icons/paypal"
/* Map of payment provider_id to their title and icon. Add in any payment providers you want to use. */
export const paymentInfoMap: Record<
string,
{ title: string; icon: React.JSX.Element }
> = {
pp_stripe_stripe: {
title: "Credit card",
icon: <CreditCard />,
},
"pp_stripe-ideal_stripe": {
title: "iDeal",
icon: <Ideal />,
},
"pp_stripe-bancontact_stripe": {
title: "Bancontact",
icon: <Bancontact />,
},
pp_paypal_paypal: {
title: "PayPal",
icon: <PayPal />,
},
pp_system_default: {
title: "Manual Payment",
icon: <CreditCard />,
},
// Add more payment providers here
}
// This only checks if it is native stripe for card payments, it ignores the other stripe-based providers
export const isStripe = (providerId?: string) => {
return providerId?.startsWith("pp_stripe_")
}
export const isPaypal = (providerId?: string) => {
return providerId?.startsWith("pp_paypal")
}
export const isManual = (providerId?: string) => {
return providerId?.startsWith("pp_system_default")
}
// Add currencies that don't need to be divided by 100
export const noDivisionCurrencies = [
"krw",
"jpy",
"vnd",
"clp",
"pyg",
"xaf",
"xof",
"bif",
"djf",
"gnf",
"kmf",
"mga",
"rwf",
"xpf",
"htg",
"vuv",
"xag",
"xdr",
"xau",
]

View File

@@ -0,0 +1,34 @@
"use client"
import React, { createContext, useContext } from "react"
interface ModalContext {
close: () => void
}
const ModalContext = createContext<ModalContext | null>(null)
interface ModalProviderProps {
children?: React.ReactNode
close: () => void
}
export const ModalProvider = ({ children, close }: ModalProviderProps) => {
return (
<ModalContext.Provider
value={{
close,
}}
>
{children}
</ModalContext.Provider>
)
}
export const useModal = () => {
const context = useContext(ModalContext)
if (context === null) {
throw new Error("useModal must be used within a ModalProvider")
}
return context
}

View File

@@ -0,0 +1,472 @@
"use server";
import medusaError from "@lib/util/medusa-error";
import { HttpTypes } from "@medusajs/types";
import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
import {
getAuthHeaders,
getCacheOptions,
getCacheTag,
getCartId,
removeCartId,
setCartId,
} from "./cookies";
import { getRegion } from "./regions";
import { sdk } from "@lib/config";
/**
* Retrieves a cart by its ID. If no ID is provided, it will use the cart ID from the cookies.
* @param cartId - optional - The ID of the cart to retrieve.
* @returns The cart object if found, or null if not found.
*/
export async function retrieveCart(cartId?: string) {
const id = cartId || (await getCartId());
if (!id) {
return null;
}
const headers = {
...(await getAuthHeaders()),
};
const next = {
...(await getCacheOptions("carts")),
};
return await sdk.client
.fetch<HttpTypes.StoreCartResponse>(`/store/carts/${id}`, {
method: "GET",
query: {
fields:
"*items, *region, *items.product, *items.variant, *items.thumbnail, *items.metadata, +items.total, *promotions, +shipping_methods.name",
},
headers,
next,
cache: "force-cache",
})
.then(({ cart }) => cart)
.catch(() => null);
}
export async function getOrSetCart(countryCode: string) {
const region = await getRegion(countryCode);
if (!region) {
throw new Error(`Region not found for country code: ${countryCode}`);
}
let cart = await retrieveCart();
const headers = {
...(await getAuthHeaders()),
};
if (!cart) {
const cartResp = await sdk.store.cart.create(
{ region_id: region.id },
{},
headers
);
cart = cartResp.cart;
await setCartId(cart.id);
const cartCacheTag = await getCacheTag("carts");
revalidateTag(cartCacheTag);
}
if (cart && cart?.region_id !== region.id) {
await sdk.store.cart.update(cart.id, { region_id: region.id }, {}, headers);
const cartCacheTag = await getCacheTag("carts");
revalidateTag(cartCacheTag);
}
return cart;
}
export async function updateCart(data: HttpTypes.StoreUpdateCart) {
const cartId = await getCartId();
if (!cartId) {
throw new Error(
"No existing cart found, please create one before updating"
);
}
const headers = {
...(await getAuthHeaders()),
};
return sdk.store.cart
.update(cartId, data, {}, headers)
.then(async ({ cart }) => {
const cartCacheTag = await getCacheTag("carts");
revalidateTag(cartCacheTag);
const fulfillmentCacheTag = await getCacheTag("fulfillment");
revalidateTag(fulfillmentCacheTag);
return cart;
})
.catch(medusaError);
}
export async function addToCart({
variantId,
quantity,
countryCode,
}: {
variantId: string;
quantity: number;
countryCode: string;
}) {
if (!variantId) {
throw new Error("Missing variant ID when adding to cart");
}
const cart = await getOrSetCart(countryCode);
if (!cart) {
throw new Error("Error retrieving or creating cart");
}
const headers = {
...(await getAuthHeaders()),
};
await sdk.store.cart
.createLineItem(
cart.id,
{
variant_id: variantId,
quantity,
},
{},
headers
)
.then(async () => {
const cartCacheTag = await getCacheTag("carts");
revalidateTag(cartCacheTag);
const fulfillmentCacheTag = await getCacheTag("fulfillment");
revalidateTag(fulfillmentCacheTag);
})
.catch(medusaError);
}
export async function updateLineItem({
lineId,
quantity,
}: {
lineId: string;
quantity: number;
}) {
if (!lineId) {
throw new Error("Missing lineItem ID when updating line item");
}
const cartId = await getCartId();
if (!cartId) {
throw new Error("Missing cart ID when updating line item");
}
const headers = {
...(await getAuthHeaders()),
};
await sdk.store.cart
.updateLineItem(cartId, lineId, { quantity }, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts");
revalidateTag(cartCacheTag);
const fulfillmentCacheTag = await getCacheTag("fulfillment");
revalidateTag(fulfillmentCacheTag);
})
.catch(medusaError);
}
export async function deleteLineItem(lineId: string) {
if (!lineId) {
throw new Error("Missing lineItem ID when deleting line item");
}
const cartId = await getCartId();
if (!cartId) {
throw new Error("Missing cart ID when deleting line item");
}
const headers = {
...(await getAuthHeaders()),
};
await sdk.store.cart
.deleteLineItem(cartId, lineId, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts");
revalidateTag(cartCacheTag);
const fulfillmentCacheTag = await getCacheTag("fulfillment");
revalidateTag(fulfillmentCacheTag);
})
.catch(medusaError);
}
export async function setShippingMethod({
cartId,
shippingMethodId,
}: {
cartId: string;
shippingMethodId: string;
}) {
const headers = {
...(await getAuthHeaders()),
};
return sdk.store.cart
.addShippingMethod(cartId, { option_id: shippingMethodId }, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts");
revalidateTag(cartCacheTag);
})
.catch(medusaError);
}
export async function initiatePaymentSession(
cart: HttpTypes.StoreCart,
data: HttpTypes.StoreInitializePaymentSession
) {
const headers = {
...(await getAuthHeaders()),
};
return sdk.store.payment
.initiatePaymentSession(cart, data, {}, headers)
.then(async (resp) => {
const cartCacheTag = await getCacheTag("carts");
revalidateTag(cartCacheTag);
return resp;
})
.catch(medusaError);
}
export async function applyPromotions(codes: string[]) {
const cartId = await getCartId();
if (!cartId) {
throw new Error("No existing cart found");
}
const headers = {
...(await getAuthHeaders()),
};
return sdk.store.cart
.update(cartId, { promo_codes: codes }, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts");
revalidateTag(cartCacheTag);
const fulfillmentCacheTag = await getCacheTag("fulfillment");
revalidateTag(fulfillmentCacheTag);
})
.catch(medusaError);
}
export async function applyGiftCard(code: string) {
// const cartId = getCartId()
// if (!cartId) return "No cartId cookie found"
// try {
// await updateCart(cartId, { gift_cards: [{ code }] }).then(() => {
// revalidateTag("cart")
// })
// } catch (error: any) {
// throw error
// }
}
export async function removeDiscount(code: string) {
// const cartId = getCartId()
// if (!cartId) return "No cartId cookie found"
// try {
// await deleteDiscount(cartId, code)
// revalidateTag("cart")
// } catch (error: any) {
// throw error
// }
}
export async function removeGiftCard(
codeToRemove: string,
giftCards: any[]
// giftCards: GiftCard[]
) {
// const cartId = getCartId()
// if (!cartId) return "No cartId cookie found"
// try {
// await updateCart(cartId, {
// gift_cards: [...giftCards]
// .filter((gc) => gc.code !== codeToRemove)
// .map((gc) => ({ code: gc.code })),
// }).then(() => {
// revalidateTag("cart")
// })
// } catch (error: any) {
// throw error
// }
}
export async function submitPromotionForm(
currentState: unknown,
formData: FormData
) {
const code = formData.get("code") as string;
try {
await applyPromotions([code]);
} catch (e: any) {
return e.message;
}
}
// TODO: Pass a POJO instead of a form entity here
export async function setAddresses(currentState: unknown, formData: FormData) {
try {
if (!formData) {
throw new Error("No form data found when setting addresses");
}
const cartId = getCartId();
if (!cartId) {
throw new Error("No existing cart found when setting addresses");
}
const data = {
shipping_address: {
first_name: formData.get("shipping_address.first_name"),
last_name: formData.get("shipping_address.last_name"),
address_1: formData.get("shipping_address.address_1"),
address_2: "",
company: formData.get("shipping_address.company"),
postal_code: formData.get("shipping_address.postal_code"),
city: formData.get("shipping_address.city"),
country_code: formData.get("shipping_address.country_code"),
province: formData.get("shipping_address.province"),
phone: formData.get("shipping_address.phone"),
},
email: formData.get("email"),
} as any;
const sameAsBilling = formData.get("same_as_billing");
if (sameAsBilling === "on") data.billing_address = data.shipping_address;
if (sameAsBilling !== "on")
data.billing_address = {
first_name: formData.get("billing_address.first_name"),
last_name: formData.get("billing_address.last_name"),
address_1: formData.get("billing_address.address_1"),
address_2: "",
company: formData.get("billing_address.company"),
postal_code: formData.get("billing_address.postal_code"),
city: formData.get("billing_address.city"),
country_code: formData.get("billing_address.country_code"),
province: formData.get("billing_address.province"),
phone: formData.get("billing_address.phone"),
};
await updateCart(data);
} catch (e: any) {
return e.message;
}
redirect(
`/${formData.get("shipping_address.country_code")}/checkout?step=delivery`
);
}
/**
* Places an order for a cart. If no cart ID is provided, it will use the cart ID from the cookies.
* @param cartId - optional - The ID of the cart to place an order for.
* @returns The cart object if the order was successful, or null if not.
*/
export async function placeOrder(cartId?: string) {
const id = cartId || (await getCartId());
if (!id) {
throw new Error("No existing cart found when placing an order");
}
const headers = {
...(await getAuthHeaders()),
};
const cartRes = await sdk.store.cart
.complete(id, {}, headers)
.then(async (cartRes) => {
const cartCacheTag = await getCacheTag("carts");
revalidateTag(cartCacheTag);
return cartRes;
})
.catch(medusaError);
if (cartRes?.type === "order") {
const countryCode =
cartRes.order.shipping_address?.country_code?.toLowerCase();
const orderCacheTag = await getCacheTag("orders");
revalidateTag(orderCacheTag);
removeCartId();
redirect(`/${countryCode}/order/${cartRes?.order.id}/confirmed`);
}
return cartRes.cart;
}
/**
* Updates the countrycode param and revalidates the regions cache
* @param regionId
* @param countryCode
*/
export async function updateRegion(countryCode: string, currentPath: string) {
const cartId = await getCartId();
const region = await getRegion(countryCode);
if (!region) {
throw new Error(`Region not found for country code: ${countryCode}`);
}
if (cartId) {
await updateCart({ region_id: region.id });
const cartCacheTag = await getCacheTag("carts");
revalidateTag(cartCacheTag);
}
const regionCacheTag = await getCacheTag("regions");
revalidateTag(regionCacheTag);
const productsCacheTag = await getCacheTag("products");
revalidateTag(productsCacheTag);
redirect(`/${countryCode}${currentPath}`);
}
export async function listCartOptions() {
const cartId = await getCartId();
const headers = {
...(await getAuthHeaders()),
};
const next = {
...(await getCacheOptions("shippingOptions")),
};
return await sdk.client.fetch<{
shipping_options: HttpTypes.StoreCartShippingOption[];
}>("/store/shipping-options", {
query: { cart_id: cartId },
next,
headers,
cache: "force-cache",
});
}

View File

@@ -0,0 +1,49 @@
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getCacheOptions } from "./cookies"
export const listCategories = async (query?: Record<string, any>) => {
const next = {
...(await getCacheOptions("categories")),
}
const limit = query?.limit || 100
return sdk.client
.fetch<{ product_categories: HttpTypes.StoreProductCategory[] }>(
"/store/product-categories",
{
query: {
fields:
"*category_children, *products, *parent_category, *parent_category.parent_category",
limit,
...query,
},
next,
cache: "force-cache",
}
)
.then(({ product_categories }) => product_categories)
}
export const getCategoryByHandle = async (categoryHandle: string[]) => {
const handle = `${categoryHandle.join("/")}`
const next = {
...(await getCacheOptions("categories")),
}
return sdk.client
.fetch<HttpTypes.StoreProductCategoryListResponse>(
`/store/product-categories`,
{
query: {
fields: "*category_children, *products",
handle,
},
next,
cache: "force-cache",
}
)
.then(({ product_categories }) => product_categories[0])
}

View File

@@ -0,0 +1,59 @@
"use server"
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getCacheOptions } from "./cookies"
export const retrieveCollection = async (id: string) => {
const next = {
...(await getCacheOptions("collections")),
}
return sdk.client
.fetch<{ collection: HttpTypes.StoreCollection }>(
`/store/collections/${id}`,
{
next,
cache: "force-cache",
}
)
.then(({ collection }) => collection)
}
export const listCollections = async (
queryParams: Record<string, string> = {}
): Promise<{ collections: HttpTypes.StoreCollection[]; count: number }> => {
const next = {
...(await getCacheOptions("collections")),
}
queryParams.limit = queryParams.limit || "100"
queryParams.offset = queryParams.offset || "0"
return sdk.client
.fetch<{ collections: HttpTypes.StoreCollection[]; count: number }>(
"/store/collections",
{
query: queryParams,
next,
cache: "force-cache",
}
)
.then(({ collections }) => ({ collections, count: collections.length }))
}
export const getCollectionByHandle = async (
handle: string
): Promise<HttpTypes.StoreCollection> => {
const next = {
...(await getCacheOptions("collections")),
}
return sdk.client
.fetch<HttpTypes.StoreCollectionListResponse>(`/store/collections`, {
query: { handle, fields: "*products" },
next,
cache: "force-cache",
})
.then(({ collections }) => collections[0])
}

View File

@@ -0,0 +1,89 @@
import "server-only"
import { cookies as nextCookies } from "next/headers"
export const getAuthHeaders = async (): Promise<
{ authorization: string } | {}
> => {
try {
const cookies = await nextCookies()
const token = cookies.get("_medusa_jwt")?.value
if (!token) {
return {}
}
return { authorization: `Bearer ${token}` }
} catch {
return {}
}
}
export const getCacheTag = async (tag: string): Promise<string> => {
try {
const cookies = await nextCookies()
const cacheId = cookies.get("_medusa_cache_id")?.value
if (!cacheId) {
return ""
}
return `${tag}-${cacheId}`
} catch (error) {
return ""
}
}
export const getCacheOptions = async (
tag: string
): Promise<{ tags: string[] } | {}> => {
if (typeof window !== "undefined") {
return {}
}
const cacheTag = await getCacheTag(tag)
if (!cacheTag) {
return {}
}
return { tags: [`${cacheTag}`] }
}
export const setAuthToken = async (token: string) => {
const cookies = await nextCookies()
cookies.set("_medusa_jwt", token, {
maxAge: 60 * 60 * 24 * 7,
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
})
}
export const removeAuthToken = async () => {
const cookies = await nextCookies()
cookies.set("_medusa_jwt", "", {
maxAge: -1,
})
}
export const getCartId = async () => {
const cookies = await nextCookies()
return cookies.get("_medusa_cart_id")?.value
}
export const setCartId = async (cartId: string) => {
const cookies = await nextCookies()
cookies.set("_medusa_cart_id", cartId, {
maxAge: 60 * 60 * 24 * 7,
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
})
}
export const removeCartId = async () => {
const cookies = await nextCookies()
cookies.set("_medusa_cart_id", "", {
maxAge: -1,
})
}

View File

@@ -0,0 +1,261 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import {
getAuthHeaders,
getCacheOptions,
getCacheTag,
getCartId,
removeAuthToken,
removeCartId,
setAuthToken,
} from "./cookies"
export const retrieveCustomer =
async (): Promise<HttpTypes.StoreCustomer | null> => {
const authHeaders = await getAuthHeaders()
if (!authHeaders) return null
const headers = {
...authHeaders,
}
const next = {
...(await getCacheOptions("customers")),
}
return await sdk.client
.fetch<{ customer: HttpTypes.StoreCustomer }>(`/store/customers/me`, {
method: "GET",
query: {
fields: "*orders",
},
headers,
next,
cache: "force-cache",
})
.then(({ customer }) => customer)
.catch(() => null)
}
export const updateCustomer = async (body: HttpTypes.StoreUpdateCustomer) => {
const headers = {
...(await getAuthHeaders()),
}
const updateRes = await sdk.store.customer
.update(body, {}, headers)
.then(({ customer }) => customer)
.catch(medusaError)
const cacheTag = await getCacheTag("customers")
revalidateTag(cacheTag)
return updateRes
}
export async function signup(_currentState: unknown, formData: FormData) {
const password = formData.get("password") as string
const customerForm = {
email: formData.get("email") as string,
first_name: formData.get("first_name") as string,
last_name: formData.get("last_name") as string,
phone: formData.get("phone") as string,
}
try {
const token = await sdk.auth.register("customer", "emailpass", {
email: customerForm.email,
password: password,
})
await setAuthToken(token as string)
const headers = {
...(await getAuthHeaders()),
}
const { customer: createdCustomer } = await sdk.store.customer.create(
customerForm,
{},
headers
)
const loginToken = await sdk.auth.login("customer", "emailpass", {
email: customerForm.email,
password,
})
await setAuthToken(loginToken as string)
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
await transferCart()
return createdCustomer
} catch (error: any) {
return error.toString()
}
}
export async function login(_currentState: unknown, formData: FormData) {
const email = formData.get("email") as string
const password = formData.get("password") as string
try {
await sdk.auth
.login("customer", "emailpass", { email, password })
.then(async (token) => {
await setAuthToken(token as string)
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
})
} catch (error: any) {
return error.toString()
}
try {
await transferCart()
} catch (error: any) {
return error.toString()
}
}
export async function signout(countryCode: string) {
await sdk.auth.logout()
await removeAuthToken()
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
await removeCartId()
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
redirect(`/${countryCode}/account`)
}
export async function transferCart() {
const cartId = await getCartId()
if (!cartId) {
return
}
const headers = await getAuthHeaders()
await sdk.store.cart.transferCart(cartId, {}, headers)
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
}
export const addCustomerAddress = async (
currentState: Record<string, unknown>,
formData: FormData
): Promise<any> => {
const isDefaultBilling = (currentState.isDefaultBilling as boolean) || false
const isDefaultShipping = (currentState.isDefaultShipping as boolean) || false
const address = {
first_name: formData.get("first_name") as string,
last_name: formData.get("last_name") as string,
company: formData.get("company") as string,
address_1: formData.get("address_1") as string,
address_2: formData.get("address_2") as string,
city: formData.get("city") as string,
postal_code: formData.get("postal_code") as string,
province: formData.get("province") as string,
country_code: formData.get("country_code") as string,
phone: formData.get("phone") as string,
is_default_billing: isDefaultBilling,
is_default_shipping: isDefaultShipping,
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.customer
.createAddress(address, {}, headers)
.then(async ({ customer }) => {
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
return { success: true, error: null }
})
.catch((err) => {
return { success: false, error: err.toString() }
})
}
export const deleteCustomerAddress = async (
addressId: string
): Promise<void> => {
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.customer
.deleteAddress(addressId, headers)
.then(async () => {
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
return { success: true, error: null }
})
.catch((err) => {
return { success: false, error: err.toString() }
})
}
export const updateCustomerAddress = async (
currentState: Record<string, unknown>,
formData: FormData
): Promise<any> => {
const addressId =
(currentState.addressId as string) || (formData.get("addressId") as string)
if (!addressId) {
return { success: false, error: "Address ID is required" }
}
const address = {
first_name: formData.get("first_name") as string,
last_name: formData.get("last_name") as string,
company: formData.get("company") as string,
address_1: formData.get("address_1") as string,
address_2: formData.get("address_2") as string,
city: formData.get("city") as string,
postal_code: formData.get("postal_code") as string,
province: formData.get("province") as string,
country_code: formData.get("country_code") as string,
} as HttpTypes.StoreUpdateCustomerAddress
const phone = formData.get("phone") as string
if (phone) {
address.phone = phone
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.customer
.updateAddress(addressId, address, {}, headers)
.then(async () => {
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
return { success: true, error: null }
})
.catch((err) => {
return { success: false, error: err.toString() }
})
}

View File

@@ -0,0 +1,70 @@
"use server"
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getAuthHeaders, getCacheOptions } from "./cookies"
export const listCartShippingMethods = async (cartId: string) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
return sdk.client
.fetch<HttpTypes.StoreShippingOptionListResponse>(
`/store/shipping-options`,
{
method: "GET",
query: {
cart_id: cartId,
fields:
"+service_zone.fulfllment_set.type,*service_zone.fulfillment_set.location.address",
},
headers,
next,
cache: "force-cache",
}
)
.then(({ shipping_options }) => shipping_options)
.catch(() => {
return null
})
}
export const calculatePriceForShippingOption = async (
optionId: string,
cartId: string,
data?: Record<string, unknown>
) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
const body = { cart_id: cartId, data }
if (data) {
body.data = data
}
return sdk.client
.fetch<{ shipping_option: HttpTypes.StoreCartShippingOption }>(
`/store/shipping-options/${optionId}/calculate`,
{
method: "POST",
body,
headers,
next,
}
)
.then(({ shipping_option }) => shipping_option)
.catch((e) => {
return null
})
}

View File

@@ -0,0 +1,11 @@
export * from "./cart";
export * from "./categories";
export * from "./collections";
export * from "./cookies";
export * from "./customer";
export * from "./fulfillment";
export * from "./onboarding";
export * from "./orders";
export * from "./payment";
export * from "./products";
export * from "./regions";

View File

@@ -0,0 +1,9 @@
"use server"
import { cookies as nextCookies } from "next/headers"
import { redirect } from "next/navigation"
export async function resetOnboardingState(orderId: string) {
const cookies = await nextCookies()
cookies.set("_medusa_onboarding", "false", { maxAge: -1 })
redirect(`http://localhost:7001/a/orders/${orderId}`)
}

View File

@@ -0,0 +1,112 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { getAuthHeaders, getCacheOptions } from "./cookies"
import { HttpTypes } from "@medusajs/types"
export const retrieveOrder = async (id: string) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("orders")),
}
return sdk.client
.fetch<HttpTypes.StoreOrderResponse>(`/store/orders/${id}`, {
method: "GET",
query: {
fields:
"*payment_collections.payments,*items,*items.metadata,*items.variant,*items.product",
},
headers,
next,
cache: "force-cache",
})
.then(({ order }) => order)
.catch((err) => medusaError(err))
}
export const listOrders = async (
limit: number = 10,
offset: number = 0,
filters?: Record<string, any>
) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("orders")),
}
return sdk.client
.fetch<HttpTypes.StoreOrderListResponse>(`/store/orders`, {
method: "GET",
query: {
limit,
offset,
order: "-created_at",
fields: "*items,+items.metadata,*items.variant,*items.product",
...filters,
},
headers,
next,
cache: "force-cache",
})
.then(({ orders }) => orders)
.catch((err) => medusaError(err))
}
export const createTransferRequest = async (
state: {
success: boolean
error: string | null
order: HttpTypes.StoreOrder | null
},
formData: FormData
): Promise<{
success: boolean
error: string | null
order: HttpTypes.StoreOrder | null
}> => {
const id = formData.get("order_id") as string
if (!id) {
return { success: false, error: "Order ID is required", order: null }
}
const headers = await getAuthHeaders()
return await sdk.store.order
.requestTransfer(
id,
{},
{
fields: "id, email",
},
headers
)
.then(({ order }) => ({ success: true, error: null, order }))
.catch((err) => ({ success: false, error: err.message, order: null }))
}
export const acceptTransferRequest = async (id: string, token: string) => {
const headers = await getAuthHeaders()
return await sdk.store.order
.acceptTransfer(id, { token }, {}, headers)
.then(({ order }) => ({ success: true, error: null, order }))
.catch((err) => ({ success: false, error: err.message, order: null }))
}
export const declineTransferRequest = async (id: string, token: string) => {
const headers = await getAuthHeaders()
return await sdk.store.order
.declineTransfer(id, { token }, {}, headers)
.then(({ order }) => ({ success: true, error: null, order }))
.catch((err) => ({ success: false, error: err.message, order: null }))
}

View File

@@ -0,0 +1,35 @@
"use server"
import { sdk } from "@lib/config"
import { getAuthHeaders, getCacheOptions } from "./cookies"
import { HttpTypes } from "@medusajs/types"
export const listCartPaymentMethods = async (regionId: string) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("payment_providers")),
}
return sdk.client
.fetch<HttpTypes.StorePaymentProviderListResponse>(
`/store/payment-providers`,
{
method: "GET",
query: { region_id: regionId },
headers,
next,
cache: "force-cache",
}
)
.then(({ payment_providers }) =>
payment_providers.sort((a, b) => {
return a.id > b.id ? 1 : -1
})
)
.catch(() => {
return null
})
}

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