8 Commits

65 changed files with 2454 additions and 343 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import ComparePackagesModal from '../../_components/compare-packages-modal';
import { loadAnalysisPackages } from '../../_lib/server/load-analysis-packages';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
@@ -19,6 +20,8 @@ export const generateMetadata = async () => {
};
async function OrderAnalysisPackagePage() {
const { analysisPackages, countryCode } = await loadAnalysisPackages();
return (
<PageBody>
<div className="space-y-3 text-center">
@@ -26,6 +29,7 @@ async function OrderAnalysisPackagePage() {
<Trans i18nKey={'marketing:selectPackage'} />
</h3>
<ComparePackagesModal
analysisPackages={analysisPackages}
triggerElement={
<Button variant="secondary" className="gap-2">
<Trans i18nKey={'marketing:comparePackages'} />
@@ -34,7 +38,7 @@ async function OrderAnalysisPackagePage() {
}
/>
</div>
<SelectAnalysisPackages />
<SelectAnalysisPackages analysisPackages={analysisPackages} countryCode={countryCode} />
</PageBody>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import SelectAnalysisPackages from '@/components/select-analysis-packages';
import { MedReportLogo } from '../../components/med-report-logo';
import pathsConfig from '../../config/paths.config';
import ComparePackagesModal from '../home/(user)/_components/compare-packages-modal';
import { loadAnalysisPackages } from '../home/(user)/_lib/server/load-analysis-packages';
export const generateMetadata = async () => {
const { t } = await createI18nServerInstance();
@@ -23,6 +24,8 @@ export const generateMetadata = async () => {
};
async function SelectPackagePage() {
const { analysisPackages, countryCode } = await loadAnalysisPackages();
return (
<div className="container mx-auto my-24 flex flex-col items-center space-y-12">
<MedReportLogo />
@@ -31,6 +34,7 @@ async function SelectPackagePage() {
<Trans i18nKey={'marketing:selectPackage'} />
</h3>
<ComparePackagesModal
analysisPackages={analysisPackages}
triggerElement={
<Button variant="secondary" className="gap-2">
<Trans i18nKey={'marketing:comparePackages'} />
@@ -39,7 +43,7 @@ async function SelectPackagePage() {
}
/>
</div>
<SelectAnalysisPackages />
<SelectAnalysisPackages analysisPackages={analysisPackages} countryCode={countryCode} />
<Link href={pathsConfig.app.home}>
<Button variant="secondary" className="align-center">
<Trans i18nKey={'marketing:notInterestedInAudit'} />{' '}

View File

@@ -0,0 +1,99 @@
"use client";
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import Image from 'next/image';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { StoreProduct, StoreProductVariant } from '@medusajs/types';
import { Button } from '@medusajs/ui';
import { handleAddToCart } from '@/lib/services/medusaCart.service';
import { PackageHeader } from './package-header';
import { ButtonTooltip } from './ui/button-tooltip';
export interface IAnalysisPackage {
titleKey: string;
price: number;
nrOfAnalyses: number | string;
tagColor: string;
descriptionKey: string;
}
export default function SelectAnalysisPackage({
analysisPackage,
countryCode,
}: {
analysisPackage: StoreProduct
countryCode: string,
}) {
const { t, i18n: { language } } = useTranslation();
const [isAddingToCart, setIsAddingToCart] = useState(false);
const handleSelect = async (selectedVariant: StoreProductVariant) => {
if (!selectedVariant?.id) return null
setIsAddingToCart(true);
await handleAddToCart({
selectedVariant,
countryCode,
});
setIsAddingToCart(false);
}
const titleKey = analysisPackage.title;
const nrOfAnalyses = analysisPackage?.metadata?.nrOfAnalyses ?? 0;
const description = analysisPackage.description ?? '';
const subtitle = analysisPackage.subtitle ?? '';
const variant = analysisPackage.variants?.[0];
if (!variant) {
return null;
}
const price = variant.calculated_price?.calculated_amount ?? 0;
return (
<Card key={titleKey}>
<CardHeader className="relative">
{description && (
<ButtonTooltip
content={description}
className="absolute top-5 right-5 z-10"
/>
)}
<Image
src="/assets/card-image.png"
alt="background"
width={326}
height={195}
className="max-h-48 w-full opacity-10"
/>
</CardHeader>
<CardContent className="space-y-1 text-center">
<PackageHeader
title={t(titleKey)}
tagColor='bg-cyan'
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
language={language}
price={price}
/>
<CardDescription>
{subtitle}
</CardDescription>
</CardContent>
<CardFooter>
<Button className="w-full" onClick={() => handleSelect(variant)} isLoading={isAddingToCart}>
{!isAddingToCart && <Trans i18nKey='order-analysis-package:selectThisPackage' />}
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -1,104 +1,15 @@
'use client';
import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { StoreProduct } from '@medusajs/types';
import { PackageHeader } from './package-header';
import { ButtonTooltip } from './ui/button-tooltip';
export interface IAnalysisPackage {
titleKey: string;
price: number;
nrOfAnalyses: number | string;
tagColor: string;
descriptionKey: string;
}
const analysisPackages = [
{
titleKey: 'product:standard.label',
price: 40,
nrOfAnalyses: 4,
tagColor: 'bg-cyan',
descriptionKey: 'marketing:standard.description',
},
{
titleKey: 'product:standardPlus.label',
price: 85,
nrOfAnalyses: 10,
tagColor: 'bg-warning',
descriptionKey: 'product:standardPlus.description',
},
{
titleKey: 'product:premium.label',
price: 140,
nrOfAnalyses: '12+',
tagColor: 'bg-purple',
descriptionKey: 'product:premium.description',
},
] satisfies IAnalysisPackage[];
export default function SelectAnalysisPackages() {
const {
t,
i18n: { language },
} = useTranslation();
import SelectAnalysisPackage from './select-analysis-package';
export default function SelectAnalysisPackages({ analysisPackages, countryCode }: { analysisPackages: StoreProduct[], countryCode: string }) {
return (
<div className="grid grid-cols-3 gap-6">
{analysisPackages.length > 0 ? analysisPackages.map(
(
{ titleKey, price, nrOfAnalyses, tagColor, descriptionKey },
index,
) => {
return (
<Card key={index}>
<CardHeader className="relative">
<ButtonTooltip
content="Content pending"
className="absolute top-5 right-5 z-10"
/>
<Image
src="/assets/card-image.png"
alt="background"
width={326}
height={195}
className="max-h-48 w-full opacity-10"
/>
</CardHeader>
<CardContent className="space-y-1 text-center">
<PackageHeader
title={t(titleKey)}
tagColor={tagColor}
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
language={language}
price={price}
/>
<CardDescription>
<Trans i18nKey={descriptionKey} />
</CardDescription>
</CardContent>
<CardFooter>
<Button className="w-full">
<Trans i18nKey='order-analysis-package:selectThisPackage' />
</Button>
</CardFooter>
</Card>
);
},
) : (
(product) => (
<SelectAnalysisPackage key={product.title} analysisPackage={product} countryCode={countryCode} />
)) : (
<h4>
<Trans i18nKey='order-analysis-package:noPackagesAvailable' />
</h4>

View File

@@ -36,6 +36,7 @@ export const defaultI18nNamespaces = [
'product',
'booking',
'order-analysis-package',
'cart',
];
/**

View File

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

View File

@@ -68,6 +68,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"fast-xml-parser": "^5.2.5",
"jsonwebtoken": "9.0.2",
"lodash": "^4.17.21",
"lucide-react": "^0.510.0",
"next": "15.3.2",
@@ -92,6 +93,7 @@
"@medusajs/ui-preset": "latest",
"@next/bundle-analyzer": "15.3.2",
"@tailwindcss/postcss": "^4.1.10",
"@types/jsonwebtoken": "9.0.10",
"@types/lodash": "^4.17.17",
"@types/node": "^22.15.32",
"@types/react": "19.1.4",

View File

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

View File

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

View File

@@ -23,6 +23,7 @@
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/stripe": "workspace:*",
"@kit/montonio": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -0,0 +1,35 @@
{
"name": "@kit/montonio",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@kit/billing": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/react": "19.1.4",
"date-fns": "^4.1.0",
"next": "15.3.2",
"react": "19.1.0"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -0,0 +1,2 @@
export { MontonioWebhookHandlerService } from './services/montonio-webhook-handler.service';
export { MontonioOrderHandlerService } from './services/montonio-order-handler.service';

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const MontonioClientEnvSchema = z
.object({
accessKey: z.string().min(1),
});

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
export const MontonioServerEnvSchema = z
.object({
secretKey: z
.string({
required_error: `Please provide the variable MONTONIO_SECRET_KEY`,
})
.min(1),
apiUrl: z
.string({
required_error: `Please provide the variable MONTONIO_API_URL`,
})
.min(1),
});

View File

@@ -0,0 +1,63 @@
import jwt from 'jsonwebtoken';
import axios, { AxiosError } from 'axios';
import { MontonioClientEnvSchema } from '../schema/montonio-client-env.schema';
import { MontonioServerEnvSchema } from '../schema/montonio-server-env.schema';
const { accessKey } = MontonioClientEnvSchema.parse({
accessKey: process.env.NEXT_PUBLIC_MONTONIO_ACCESS_KEY,
});
const { apiUrl, secretKey } = MontonioServerEnvSchema.parse({
apiUrl: process.env.MONTONIO_API_URL,
secretKey: process.env.MONTONIO_SECRET_KEY,
});
export class MontonioOrderHandlerService {
public async getMontonioPaymentLink({
notificationUrl,
returnUrl,
amount,
currency,
description,
locale,
merchantReference,
}: {
notificationUrl: string;
returnUrl: string;
amount: number;
currency: string;
description: string;
locale: string;
merchantReference: string;
}) {
const token = jwt.sign({
accessKey,
description,
currency,
amount,
locale,
// 15 minutes
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
notificationUrl,
returnUrl,
askAdditionalInfo: false,
merchantReference,
type: "one_time",
}, secretKey, {
algorithm: "HS256",
expiresIn: "10m",
});
try {
const { data } = await axios.post(`${apiUrl}/api/payment-links`, { data: token });
return data.url;
} catch (error) {
if (error instanceof AxiosError) {
console.error(error.response?.data);
}
console.error(error);
throw new Error("Failed to create payment link");
}
}
}

View File

@@ -0,0 +1,111 @@
import type { BillingWebhookHandlerService, IHandleWebhookEventParams } from '@kit/billing';
import { getLogger } from '@kit/shared/logger';
import { Database, Enums } from '@kit/supabase/database';
import jwt from 'jsonwebtoken';
import { MontonioServerEnvSchema } from '../schema/montonio-server-env.schema';
type UpsertOrderParams =
Database['medreport']['Functions']['upsert_order']['Args'];
type BillingProvider = Enums<{ schema: 'medreport' }, 'billing_provider'>;
interface MontonioOrderToken {
uuid: string;
accessKey: string;
merchantReference: string;
merchantReferenceDisplay: string;
paymentStatus: 'PAID' | 'FAILED' | 'CANCELLED' | 'PENDING' | 'EXPIRED' | 'REFUNDED';
paymentMethod: string;
grandTotal: number;
currency: string;
senderIban?: string;
senderName?: string;
paymentProviderName?: string;
paymentLinkUuid: string;
iat: number;
exp: number;
}
const { secretKey } = MontonioServerEnvSchema.parse({
apiUrl: process.env.MONTONIO_API_URL,
secretKey: process.env.MONTONIO_SECRET_KEY,
});
export class MontonioWebhookHandlerService
implements BillingWebhookHandlerService
{
private readonly provider: BillingProvider = 'montonio';
private readonly namespace = 'billing.montonio';
async verifyWebhookSignature(request: Request) {
const logger = await getLogger();
let token: string;
try {
const url = new URL(request.url);
const searchParams = url.searchParams;
console.info("searchParams", searchParams, url);
const tokenParam = searchParams.get('order-token') as string | null;
if (!tokenParam) {
throw new Error('Missing order-token');
}
token = tokenParam;
} catch (error) {
logger.error({
error,
name: this.namespace,
}, `Failed to parse Montonio webhook request`);
throw new Error('Invalid request');
}
try {
const decoded = jwt.verify(token, secretKey, {
algorithms: ['HS256'],
});
return decoded as MontonioOrderToken;
} catch (error) {
logger.error({
error,
name: this.namespace,
}, `Failed to verify Montonio webhook signature`);
throw new Error('Invalid signature');
}
}
async handleWebhookEvent(
event: MontonioOrderToken,
params: IHandleWebhookEventParams
) {
const logger = await getLogger();
logger.info({
name: this.namespace,
event,
}, `Received Montonio webhook event`);
if (event.paymentStatus === 'PAID') {
const accountId = event.merchantReferenceDisplay.split(':')[0];
if (!accountId) {
throw new Error('Invalid merchant reference');
}
const order: UpsertOrderParams = {
target_account_id: accountId,
target_customer_id: '',
target_order_id: event.uuid,
status: 'succeeded',
billing_provider: this.provider,
total_amount: event.grandTotal,
currency: event.currency,
line_items: [],
};
return params.onCheckoutSessionCompleted(order);
}
if (event.paymentStatus === 'FAILED' || event.paymentStatus === 'CANCELLED') {
return params.onPaymentFailed(event.uuid);
}
return;
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -19,6 +19,7 @@
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/stripe": "workspace:*",
"@kit/montonio": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/team-accounts": "workspace:*",
"@kit/tsconfig": "workspace:*",

View File

@@ -31,15 +31,16 @@ export function UpdateAccountForm({ user }: { user: User }) {
defaultValues: {
firstName: '',
lastName: '',
personalCode: '',
personalCode: user.user_metadata.personalCode ?? '',
email: user.email,
phone: '',
city: '',
weight: 0,
height: 0,
weight: user.user_metadata.weight ?? undefined,
height: user.user_metadata.height ?? undefined,
userConsent: false,
},
});
return (
<Form {...form}>
<form

View File

@@ -6,6 +6,7 @@ import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import pathsConfig from '~/config/paths.config';
import { updateCustomer } from '@lib/data/customer';
import { UpdateAccountSchema } from '../../schemas/update-account.schema';
import { createAuthApi } from '../api';
@@ -36,6 +37,13 @@ export const onUpdateAccount = enhanceAction(
}
console.warn('On update account error: ', err);
}
await updateCustomer({
first_name: params.firstName,
last_name: params.lastName,
phone: params.phone,
});
const hasUnseenMembershipConfirmation =
await api.hasUnseenMembershipConfirmation();

View File

@@ -154,6 +154,8 @@ export async function addToCart({
revalidateTag(fulfillmentCacheTag);
})
.catch(medusaError);
return cart;
}
export async function updateLineItem({
@@ -394,7 +396,7 @@ export async function placeOrder(cartId?: string) {
const id = cartId || (await getCartId());
if (!id) {
throw new Error("No existing cart found when placing an order");
return;
}
const headers = {
@@ -411,17 +413,14 @@ export async function placeOrder(cartId?: string) {
.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`);
redirect(`/home/order/${cartRes?.order.id}/confirmed`);
} else {
throw new Error("Cart is not an order");
}
return cartRes.cart;
}
/**

View File

@@ -18,6 +18,23 @@ export const getAuthHeaders = async (): Promise<
}
}
export const getMedusaCustomerId = async (): Promise<
{ customerId: string | null }
> => {
try {
const cookies = await nextCookies()
const customerId = cookies.get("_medusa_customer_id")?.value
if (!customerId) {
return { customerId: null }
}
return { customerId }
} catch {
return { customerId: null}
}
}
export const getCacheTag = async (tag: string): Promise<string> => {
try {
const cookies = await nextCookies()
@@ -59,6 +76,16 @@ export const setAuthToken = async (token: string) => {
})
}
export const setMedusaCustomerId = async (customerId: string) => {
const cookies = await nextCookies()
cookies.set("_medusa_customer_id", customerId, {
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", "", {

View File

@@ -259,3 +259,51 @@ export const updateCustomerAddress = async (
return { success: false, error: err.toString() }
})
}
export async function medusaLoginOrRegister(credentials: {
email: string
password?: string
}) {
const { email, password } = credentials;
try {
const token = await sdk.auth.login("customer", "emailpass", {
email,
password,
});
await setAuthToken(token as string);
await transferCart();
const customerCacheTag = await getCacheTag("customers");
revalidateTag(customerCacheTag);
} catch (error) {
console.error("Failed to login customer, attempting to register", error);
try {
const registerToken = await sdk.auth.register("customer", "emailpass", {
email: email,
password: password,
})
await setAuthToken(registerToken as string);
const headers = {
...(await getAuthHeaders()),
};
await sdk.store.customer.create({ email }, {}, headers);
const loginToken = await sdk.auth.login("customer", "emailpass", {
email,
password,
});
await setAuthToken(loginToken as string);
const customerCacheTag = await getCacheTag("customers");
revalidateTag(customerCacheTag);
await transferCart();
} catch (registerError) {
throw medusaError(registerError);
}
}
}

View File

@@ -14,7 +14,7 @@ export const listProducts = async ({
regionId,
}: {
pageParam?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { collection_id?: string }
countryCode?: string
regionId?: string
}): Promise<{

View File

@@ -57,7 +57,7 @@ export const getRegion = async (countryCode: string) => {
const region = countryCode
? regionMap.get(countryCode)
: regionMap.get("us")
: regionMap.get("et")
return region
} catch (e: any) {

View File

@@ -3,24 +3,34 @@
import { deleteLineItem } from "@lib/data/cart";
import { Spinner, Trash } from "@medusajs/icons";
import { clx } from "@medusajs/ui";
import { useRouter } from "next/navigation";
import { useState } from "react";
const DeleteButton = ({
id,
children,
className,
Icon,
}: {
id: string;
children?: React.ReactNode;
className?: string;
Icon?: React.ReactNode;
}) => {
const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter();
const handleDelete = async (id: string) => {
setIsDeleting(true);
await deleteLineItem(id).catch((err) => {
try {
await deleteLineItem(id);
router.refresh();
} catch (err) {
// TODO: display a toast notification with the error
setIsDeleting(false);
});
}
};
return (
@@ -34,7 +44,7 @@ const DeleteButton = ({
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer"
onClick={() => handleDelete(id)}
>
{isDeleting ? <Spinner className="animate-spin" /> : <Trash />}
{isDeleting ? <Spinner className="animate-spin" /> : (Icon ?? <Trash />)}
<span>{children}</span>
</button>
</div>

View File

@@ -9,6 +9,10 @@ type ShippingDetailsProps = {
}
const ShippingDetails = ({ order }: ShippingDetailsProps) => {
if (!order.shipping_methods || order.shipping_methods.length === 0) {
return null;
}
return (
<div>
<Heading level="h2" className="flex flex-row text-3xl-regular my-6">
@@ -58,7 +62,7 @@ const ShippingDetails = ({ order }: ShippingDetailsProps) => {
<Text className="txt-medium text-ui-fg-subtle">
{(order as any).shipping_methods[0]?.name} (
{convertToLocale({
amount: order.shipping_methods?.[0].total ?? 0,
amount: order.shipping_methods?.[0]?.total ?? 0,
currency_code: order.currency_code,
})
.replace(/,/g, "")

View File

@@ -14,7 +14,7 @@ export function formatCurrency(params: {
locale: string;
value: string | number;
}) {
const [lang, region] = params.locale.split('-');
const [lang, region] = (params.locale ?? 'et-ET').split('-');
return new Intl.NumberFormat(region ?? lang, {
style: 'currency',

View File

@@ -1736,7 +1736,7 @@ export type Database = {
| "settings.manage"
| "members.manage"
| "invites.manage"
billing_provider: "stripe" | "lemon-squeezy" | "paddle"
billing_provider: "stripe" | "lemon-squeezy" | "paddle" | "montonio"
notification_channel: "in_app" | "email"
notification_type: "info" | "warning" | "error"
payment_status: "pending" | "succeeded" | "failed"
@@ -1938,7 +1938,7 @@ export const Constants = {
"members.manage",
"invites.manage",
],
billing_provider: ["stripe", "lemon-squeezy", "paddle"],
billing_provider: ["stripe", "lemon-squeezy", "paddle", "montonio"],
notification_channel: ["in_app", "email"],
notification_type: ["info", "warning", "error"],
payment_status: ["pending", "succeeded", "failed"],

View File

@@ -2,6 +2,7 @@ import type { SignInWithPasswordCredentials } from '@supabase/supabase-js';
import { useMutation } from '@tanstack/react-query';
import { medusaLoginOrRegister } from '../../../features/medusa-storefront/src/lib/data/customer';
import { useSupabase } from './use-supabase';
export function useSignInWithEmailPassword() {
@@ -18,11 +19,22 @@ export function useSignInWithEmailPassword() {
const user = response.data?.user;
const identities = user?.identities ?? [];
// if the user has no identities, it means that the email is taken
if (identities.length === 0) {
throw new Error('User already registered');
}
if ('email' in credentials) {
try {
await medusaLoginOrRegister({
email: credentials.email,
password: credentials.password,
});
} catch (error) {
await client.auth.signOut();
throw error;
}
}
return response.data;
};

View File

@@ -1,6 +1,7 @@
import { useMutation } from '@tanstack/react-query';
import { useSupabase } from './use-supabase';
import { medusaLoginOrRegister } from '../../../features/medusa-storefront/src/lib/data/customer';
interface Credentials {
personalCode: string;
@@ -41,6 +42,18 @@ export function useSignUpWithEmailAndPassword() {
throw new Error('User already registered');
}
if ('email' in credentials) {
try {
await medusaLoginOrRegister({
email: credentials.email,
password: credentials.password,
});
} catch (error) {
await client.auth.signOut();
throw error;
}
}
return response.data;
};

View File

@@ -171,22 +171,24 @@ export function PageHeader({
<PageTitle>{title}</PageTitle>
</If>
<div className="flex items-center gap-x-2.5">
{displaySidebarTrigger ? (
<SidebarTrigger className="text-muted-foreground hover:text-secondary-foreground hidden h-4.5 w-4.5 cursor-pointer lg:inline-flex" />
) : null}
<If condition={displaySidebarTrigger || description}>
<div className="flex items-center gap-x-2.5">
{displaySidebarTrigger ? (
<SidebarTrigger className="text-muted-foreground hover:text-secondary-foreground hidden h-4.5 w-4.5 cursor-pointer lg:inline-flex" />
) : null}
<If condition={description}>
<If condition={displaySidebarTrigger}>
<Separator
orientation="vertical"
className="hidden h-4 w-px lg:group-data-[minimized]:block"
/>
<If condition={description}>
<If condition={displaySidebarTrigger}>
<Separator
orientation="vertical"
className="hidden h-4 w-px lg:group-data-[minimized]:block"
/>
</If>
<PageDescription>{description}</PageDescription>
</If>
<PageDescription>{description}</PageDescription>
</If>
</div>
</div>
</If>
</div>
{children}

View File

@@ -674,11 +674,6 @@ const SidebarMenuSkeleton: React.FC<
showIcon?: boolean;
}
> = ({ className, showIcon = false, ...props }) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-sidebar="menu-skeleton"
@@ -696,7 +691,7 @@ const SidebarMenuSkeleton: React.FC<
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
'--skeleton-width': '70%',
} as React.CSSProperties
}
/>

622
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
{
"title": "Cart",
"description": "View your cart",
"emptyCartMessage": "Your cart is empty",
"emptyCartMessageDescription": "Add items to your cart to continue.",
"subtotal": "Subtotal",
"total": "Total",
"table": {
"item": "Item",
"quantity": "Quantity",
"price": "Price",
"total": "Total"
},
"checkout": {
"goToCheckout": "Go to checkout",
"goToDashboard": "Continue",
"error": {
"title": "Something went wrong",
"description": "Please try again later."
},
"timeLeft": "Time left {{timeLeft}}",
"timeoutTitle": "Reservation expired",
"timeoutDescription": "Reservation for {{productTitle}} in shopping cart has expired.",
"timeoutAction": "Continue"
},
"discountCode": {
"label": "Add Promotion Code(s)",
"apply": "Apply",
"subtitle": "If you wish, you can add a promotion code",
"placeholder": "Enter promotion code"
},
"items": {
"analysisPackages": {
"productColumnLabel": "Package name"
},
"services": {
"productColumnLabel": "Service name"
}
},
"orderConfirmed": {
"title": "Order confirmed",
"summary": "Summary",
"subtotal": "Subtotal",
"taxes": "Taxes",
"giftCard": "Gift card",
"total": "Total",
"orderDate": "Order date",
"orderNumber": "Order number",
"orderStatus": "Order status",
"paymentStatus": "Payment status"
},
"montonioCallback": {
"title": "Montonio checkout",
"description": "Please wait while we process your payment."
}
}

View File

@@ -1,16 +1,4 @@
{
"standard": {
"label": "Standard",
"description": "Sobib, kui soovid lisaks peamistele tervisenäitajatele ülevaadet, kas organismis on olulisemaid vitamiine ja mineraalaineid piisavalt."
},
"standardPlus": {
"label": "Standard +",
"description": "Sobib, kui soovid lisaks peamistele tervisenäitajatele ülevaadet, kas organismis on olulisemaid vitamiine ja mineraalaineid piisavalt."
},
"premium": {
"label": "Premium",
"description": "Sobib, kui soovid lisaks peamistele tervisenäitajatele ülevaadet, kas organismis on olulisemaid vitamiine ja mineraalaineid piisavalt."
},
"nrOfAnalyses": "{{nr}} analyses",
"clinicalBloodDraw": {
"label": "Kliiniline vereanalüüs",

View File

@@ -0,0 +1,57 @@
{
"title": "Ostukorv",
"description": "Vaata oma ostukorvi",
"emptyCartMessage": "Sinu ostukorv on tühi",
"emptyCartMessageDescription": "Lisa tooteid ostukorvi, et jätkata.",
"subtotal": "Vahesumma",
"total": "Summa",
"table": {
"item": "Toode",
"quantity": "Kogus",
"price": "Hind",
"total": "Summa"
},
"checkout": {
"goToCheckout": "Vormista ost",
"goToDashboard": "Jätkan",
"error": {
"title": "Midagi läks valesti",
"description": "Palun proovi hiljem uuesti."
},
"timeLeft": "Aega jäänud {{timeLeft}}",
"timeoutTitle": "Broneering aegus",
"timeoutDescription": "Toote {{productTitle}} broneering ostukorvis on aegunud.",
"timeoutAction": "Jätkan"
},
"discountCode": {
"title": "Kinkekaart või sooduskood",
"label": "Lisa promo kood",
"apply": "Rakenda",
"subtitle": "Kui soovid, võid lisada promo koodi",
"placeholder": "Sisesta promo kood"
},
"items": {
"analysisPackages": {
"productColumnLabel": "Paketi nimi"
},
"services": {
"productColumnLabel": "Teenuse nimi"
}
},
"orderConfirmed": {
"title": "Tellimus on edukalt esitatud",
"summary": "Summa",
"subtotal": "Vahesumma",
"taxes": "Maksud",
"giftCard": "Kinkekaart",
"total": "Summa",
"orderDate": "Tellimuse kuupäev",
"orderNumber": "Tellimuse number",
"orderStatus": "Tellimuse olek",
"paymentStatus": "Makse olek"
},
"montonioCallback": {
"title": "Montonio makseprotsess",
"description": "Palun oodake, kuni me töötleme sinu makseprotsessi lõpuni."
}
}

View File

@@ -1,16 +1,4 @@
{
"standard": {
"label": "Standard",
"description": "Sobib, kui soovid lisaks peamistele tervisenäitajatele ülevaadet, kas organismis on olulisemaid vitamiine ja mineraalaineid piisavalt."
},
"standardPlus": {
"label": "Standard +",
"description": "Sobib, kui soovid lisaks peamistele tervisenäitajatele ülevaadet, kas organismis on olulisemaid vitamiine ja mineraalaineid piisavalt."
},
"premium": {
"label": "Premium",
"description": "Sobib, kui soovid lisaks peamistele tervisenäitajatele ülevaadet, kas organismis on olulisemaid vitamiine ja mineraalaineid piisavalt."
},
"nrOfAnalyses": "{{nr}} analüüsi",
"clinicalBloodDraw": {
"label": "Kliiniline vereanalüüs",

View File

@@ -1,16 +1,4 @@
{
"standard": {
"label": "Standard",
"description": "Sobib, kui soovid lisaks peamistele tervisenäitajatele ülevaadet, kas organismis on olulisemaid vitamiine ja mineraalaineid piisavalt."
},
"standardPlus": {
"label": "Standard +",
"description": "Sobib, kui soovid lisaks peamistele tervisenäitajatele ülevaadet, kas organismis on olulisemaid vitamiine ja mineraalaineid piisavalt."
},
"premium": {
"label": "Premium",
"description": "Sobib, kui soovid lisaks peamistele tervisenäitajatele ülevaadet, kas organismis on olulisemaid vitamiine ja mineraalaineid piisavalt."
},
"nrOfAnalyses": "{{nr}} analyses",
"clinicalBloodDraw": {
"label": "Kliiniline vereanalüüs",

View File

@@ -0,0 +1 @@
alter type public.billing_provider add value 'montonio';

View File

@@ -0,0 +1,27 @@
create table "audit"."cart_entries" (
"id" bigint generated by default as identity not null,
"account_id" text not null,
"cart_id" text not null,
"operation" text not null,
"variant_id" text,
"comment" text,
"created_at" timestamp with time zone not null default now(),
"changed_by" uuid not null
);
grant usage on schema audit to authenticated;
grant select, insert, update, delete on table audit.cart_entries to authenticated;
alter table "audit"."cart_entries" enable row level security;
create policy "insert_own"
on "audit"."cart_entries"
as permissive
for insert
to authenticated
with check (auth.uid() = changed_by);
create policy "service_role_select" on "audit"."cart_entries" for select to service_role using (true);
create policy "service_role_insert" on "audit"."cart_entries" for insert to service_role with check (true);
create policy "service_role_update" on "audit"."cart_entries" for update to service_role using (true);
create policy "service_role_delete" on "audit"."cart_entries" for delete to service_role using (true);